diff --git a/.ci/Jenkinsfile_flaky b/.ci/Jenkinsfile_flaky index 669395564db447..f702405aad69e3 100644 --- a/.ci/Jenkinsfile_flaky +++ b/.ci/Jenkinsfile_flaky @@ -99,7 +99,7 @@ def getWorkerMap(agentNumber, numberOfExecutions, worker, workerFailures, maxWor def numberOfWorkers = Math.min(numberOfExecutions, maxWorkerProcesses) for(def i = 1; i <= numberOfWorkers; i++) { - def workerExecutions = numberOfExecutions/numberOfWorkers + (i <= numberOfExecutions%numberOfWorkers ? 1 : 0) + def workerExecutions = floor(numberOfExecutions/numberOfWorkers + (i <= numberOfExecutions%numberOfWorkers ? 1 : 0)) workerMap["agent-${agentNumber}-worker-${i}"] = { workerNumber -> for(def j = 0; j < workerExecutions; j++) { diff --git a/.eslintrc.js b/.eslintrc.js index c43366abf0c3d8..a7bb204da47751 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -82,31 +82,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/kibana/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/rules-of-hooks': 'off', - 'react-hooks/exhaustive-deps': 'off', - }, - }, - { - files: ['src/legacy/core_plugins/tile_map/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, - { - files: ['src/legacy/core_plugins/vis_type_markdown/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, - { - files: ['src/legacy/core_plugins/vis_type_metric/**/*.{js,ts,tsx}'], - rules: { - 'jsx-a11y/click-events-have-key-events': 'off', - }, - }, { files: ['src/legacy/core_plugins/vis_type_table/**/*.{js,ts,tsx}'], rules: { @@ -366,9 +341,8 @@ module.exports = { 'src/fixtures/**/*.js', // TODO: this directory needs to be more obviously "public" (or go away) ], settings: { - // instructs import/no-extraneous-dependencies to treat modules - // in plugins/ or ui/ namespace as "core modules" so they don't - // trigger failures for not being listed in package.json + // instructs import/no-extraneous-dependencies to treat certain modules + // as core modules, even if they aren't listed in package.json 'import/core-modules': [ 'plugins', 'legacy/ui', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a0a22446ba31d1..9a4f2b71da1ffb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -86,6 +86,7 @@ /packages/kbn-es/ @elastic/kibana-operations /packages/kbn-pm/ @elastic/kibana-operations /packages/kbn-test/ @elastic/kibana-operations +/packages/kbn-ui-shared-deps/ @elastic/kibana-operations /src/legacy/server/keystore/ @elastic/kibana-operations /src/legacy/server/pid/ @elastic/kibana-operations /src/legacy/server/sass/ @elastic/kibana-operations diff --git a/docs/apm/settings.asciidoc b/docs/apm/settings.asciidoc index 2fc8748f13b096..37122fc9c635d0 100644 --- a/docs/apm/settings.asciidoc +++ b/docs/apm/settings.asciidoc @@ -3,8 +3,16 @@ [[apm-settings-in-kibana]] === APM settings in Kibana -You do not need to configure any settings to use APM. It is enabled by default. -If you'd like to change any of the default values, -copy and paste the relevant settings below into your `kibana.yml` configuration file. +You do not need to configure any settings to use the APM app. It is enabled by default. + +[float] +[[apm-indices-settings]] +==== APM Indices + +include::./../settings/apm-settings.asciidoc[tag=apm-indices-settings] + +[float] +[[general-apm-settings]] +==== General APM settings include::./../settings/apm-settings.asciidoc[tag=general-apm-settings] diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index ec0863b09d653c..22279b69b70feb 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -17,6 +17,7 @@ This section can help with any of the following: There are a number of factors that could be at play here. One important thing to double-check first is your index template. +*Index template* An APM index template must exist for the APM app to work correctly. By default, this index template is created by APM Server on startup. However, this only happens if `setup.template.enabled` is `true` in `apm-server.yml`. @@ -34,14 +35,21 @@ GET /_template/apm-{version} -------------------------------------------------- // CONSOLE +*Using Logstash, Kafka, etc.* If you're not outputting data directly from APM Server to Elasticsearch (perhaps you're using Logstash or Kafka), then the index template will not be set up automatically. Instead, you'll need to -{apm-server-ref}/_manually_loading_template_configuration.html#load-template-manually-alternate[load the template manually]. +{apm-server-ref}/_manually_loading_template_configuration.html[load the template manually]. -Finally, this problem can also occur if you've changed the index name that you write APM data to. -The default index pattern can be found {apm-server-ref}/elasticsearch-output.html#index-option-es[here]. -If you change this setting, you must also configure the `setup.template.name` and `setup.template.pattern` options. +*Using a custom index names* +This problem can also occur if you've customized the index name that you write APM data to. +The default index name that APM writes events to can be found +{apm-server-ref}/elasticsearch-output.html#index-option-es[here]. +If you change the default, you must also configure the `setup.template.name` and `setup.template.pattern` options. See {apm-server-ref}/configuration-template.html[Load the Elasticsearch index template]. +If the Elasticsearch index template has already been successfully loaded to the index, +you can customize the indices that the APM app uses to display data. +Navigate to *APM* > *Settings* > *Indices*, and change all `apm_oss.*Pattern` values to +include the new index pattern. For example: `customIndexName-*`. ==== Unknown route diff --git a/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md new file mode 100644 index 00000000000000..ddbf9aafbd28a5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [chromeless](./kibana-plugin-public.appbase.chromeless.md) + +## AppBase.chromeless property + +Hide the UI chrome when the application is mounted. Defaults to `false`. Takes precedence over chrome service visibility settings. + +Signature: + +```typescript +chromeless?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.id.md b/docs/development/core/public/kibana-plugin-public.appbase.id.md index 57daa0c94bdf6b..89dd32d296104c 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.id.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.id.md @@ -4,6 +4,8 @@ ## AppBase.id property +The unique identifier of the application + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-public.appbase.md b/docs/development/core/public/kibana-plugin-public.appbase.md index a93a195c559b1b..eb6d91cb924888 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.md @@ -16,10 +16,14 @@ export interface AppBase | Property | Type | Description | | --- | --- | --- | | [capabilities](./kibana-plugin-public.appbase.capabilities.md) | Partial<Capabilities> | Custom capabilities defined by the app. | +| [chromeless](./kibana-plugin-public.appbase.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | | [icon](./kibana-plugin-public.appbase.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | -| [id](./kibana-plugin-public.appbase.id.md) | string | | +| [id](./kibana-plugin-public.appbase.id.md) | string | The unique identifier of the application | +| [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) | AppNavLinkStatus | The initial status of the application's navLink. Defaulting to visible if status is accessible and hidden if status is inaccessible See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | | [order](./kibana-plugin-public.appbase.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [status](./kibana-plugin-public.appbase.status.md) | AppStatus | The initial status of the application. Defaulting to accessible | | [title](./kibana-plugin-public.appbase.title.md) | string | The title of the application. | -| [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) | Observable<string> | An observable for a tooltip shown when hovering over app link. | +| [tooltip](./kibana-plugin-public.appbase.tooltip.md) | string | A tooltip shown when hovering over app link. | +| [updater$](./kibana-plugin-public.appbase.updater_.md) | Observable<AppUpdater> | An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. | diff --git a/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md new file mode 100644 index 00000000000000..d6744c3e757568 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) + +## AppBase.navLinkStatus property + +The initial status of the application's navLink. Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +Signature: + +```typescript +navLinkStatus?: AppNavLinkStatus; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md b/docs/development/core/public/kibana-plugin-public.appbase.status.md similarity index 56% rename from docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md rename to docs/development/core/public/kibana-plugin-public.appbase.status.md index 0767ead5f1455b..a5fbadbeea1ffc 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.status.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [status](./kibana-plugin-public.appbase.status.md) -## AppBase.tooltip$ property +## AppBase.status property -An observable for a tooltip shown when hovering over app link. +The initial status of the application. Defaulting to `accessible` Signature: ```typescript -tooltip$?: Observable; +status?: AppStatus; ``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md new file mode 100644 index 00000000000000..85921a5a321dd2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip](./kibana-plugin-public.appbase.tooltip.md) + +## AppBase.tooltip property + +A tooltip shown when hovering over app link. + +Signature: + +```typescript +tooltip?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.updater_.md b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md new file mode 100644 index 00000000000000..3edd3573834491 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md @@ -0,0 +1,44 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [updater$](./kibana-plugin-public.appbase.updater_.md) + +## AppBase.updater$ property + +An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. + +Signature: + +```typescript +updater$?: Observable; +``` + +## Example + +How to update an application navLink at runtime + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + }) + } + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appleaveaction.md b/docs/development/core/public/kibana-plugin-public.appleaveaction.md new file mode 100644 index 00000000000000..ae56205f5e45ca --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleaveaction.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) + +## AppLeaveAction type + +Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) + +See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) + +Signature: + +```typescript +export declare type AppLeaveAction = AppLeaveDefaultAction | AppLeaveConfirmAction; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appleaveactiontype.md b/docs/development/core/public/kibana-plugin-public.appleaveactiontype.md new file mode 100644 index 00000000000000..482b2e489b33e4 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleaveactiontype.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) + +## AppLeaveActionType enum + +Possible type of actions on application leave. + +Signature: + +```typescript +export declare enum AppLeaveActionType +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| confirm | "confirm" | | +| default | "default" | | + diff --git a/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.md b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.md new file mode 100644 index 00000000000000..4cd99dd2a3fb3f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) + +## AppLeaveConfirmAction interface + +Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application. + +See + +Signature: + +```typescript +export interface AppLeaveConfirmAction +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [text](./kibana-plugin-public.appleaveconfirmaction.text.md) | string | | +| [title](./kibana-plugin-public.appleaveconfirmaction.title.md) | string | | +| [type](./kibana-plugin-public.appleaveconfirmaction.type.md) | AppLeaveActionType.confirm | | + diff --git a/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.text.md b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.text.md new file mode 100644 index 00000000000000..dbbd11c6f71f88 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.text.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) > [text](./kibana-plugin-public.appleaveconfirmaction.text.md) + +## AppLeaveConfirmAction.text property + +Signature: + +```typescript +text: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.title.md b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.title.md new file mode 100644 index 00000000000000..684ef384a37c35 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.title.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) > [title](./kibana-plugin-public.appleaveconfirmaction.title.md) + +## AppLeaveConfirmAction.title property + +Signature: + +```typescript +title?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.type.md b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.type.md new file mode 100644 index 00000000000000..926cecf893cc85 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleaveconfirmaction.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) > [type](./kibana-plugin-public.appleaveconfirmaction.type.md) + +## AppLeaveConfirmAction.type property + +Signature: + +```typescript +type: AppLeaveActionType.confirm; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appleavedefaultaction.md b/docs/development/core/public/kibana-plugin-public.appleavedefaultaction.md new file mode 100644 index 00000000000000..ed2f729a0c6482 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleavedefaultaction.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) + +## AppLeaveDefaultAction interface + +Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application. + +See + +Signature: + +```typescript +export interface AppLeaveDefaultAction +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-public.appleavedefaultaction.type.md) | AppLeaveActionType.default | | + diff --git a/docs/development/core/public/kibana-plugin-public.appleavedefaultaction.type.md b/docs/development/core/public/kibana-plugin-public.appleavedefaultaction.type.md new file mode 100644 index 00000000000000..ee12393121a5a4 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleavedefaultaction.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) > [type](./kibana-plugin-public.appleavedefaultaction.type.md) + +## AppLeaveDefaultAction.type property + +Signature: + +```typescript +type: AppLeaveActionType.default; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appleavehandler.md b/docs/development/core/public/kibana-plugin-public.appleavehandler.md new file mode 100644 index 00000000000000..e879227961bc6f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appleavehandler.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) + +## AppLeaveHandler type + +A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return `confirm` to to prompt a message to the user before leaving the page, or `default` to keep the default behavior (doing nothing). + +See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. + +Signature: + +```typescript +export declare type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +``` diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.md index a63de399c2ecb4..cf9bc5189af409 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.md @@ -16,5 +16,6 @@ export interface ApplicationSetup | Method | Description | | --- | --- | | [register(app)](./kibana-plugin-public.applicationsetup.register.md) | Register an mountable application to the system. | +| [registerAppUpdater(appUpdater$)](./kibana-plugin-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | | [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md new file mode 100644 index 00000000000000..39b4f878a3f795 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md @@ -0,0 +1,47 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerAppUpdater](./kibana-plugin-public.applicationsetup.registerappupdater.md) + +## ApplicationSetup.registerAppUpdater() method + +Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime. + +This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the `updater$` property of the registered application instead. + +Signature: + +```typescript +registerAppUpdater(appUpdater$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appUpdater$ | Observable<AppUpdater> | | + +Returns: + +`void` + +## Example + +How to register an application updater that disables some applications: + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + setup({ application }) { + application.registerAppUpdater( + new BehaviorSubject(app => { + if (myPluginApi.shouldDisable(app)) + return { + status: AppStatus.inaccessible, + }; + }) + ); + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.md b/docs/development/core/public/kibana-plugin-public.applicationstart.md index 4baa4565ff7b06..e36ef3f14f87e8 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.md @@ -22,6 +22,6 @@ export interface ApplicationStart | Method | Description | | --- | --- | | [getUrlForApp(appId, options)](./kibana-plugin-public.applicationstart.geturlforapp.md) | Returns a relative URL to a given app, including the global base path. | -| [navigateToApp(appId, options)](./kibana-plugin-public.applicationstart.navigatetoapp.md) | Navigiate to a given app | +| [navigateToApp(appId, options)](./kibana-plugin-public.applicationstart.navigatetoapp.md) | Navigate to a given app | | [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md b/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md index eef31fe661f54f..3e29d09ea4cd56 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.navigatetoapp.md @@ -4,7 +4,7 @@ ## ApplicationStart.navigateToApp() method -Navigiate to a given app +Navigate to a given app Signature: @@ -12,7 +12,7 @@ Navigiate to a given app navigateToApp(appId: string, options?: { path?: string; state?: any; - }): void; + }): Promise; ``` ## Parameters @@ -24,5 +24,5 @@ navigateToApp(appId: string, options?: { Returns: -`void` +`Promise` diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.md index aa5ca93ed8ff03..9586eba96a697b 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.md @@ -17,4 +17,5 @@ export interface AppMountParameters | --- | --- | --- | | [appBasePath](./kibana-plugin-public.appmountparameters.appbasepath.md) | string | The route path for configuring navigation to the application. This string should not include the base path from HTTP. | | [element](./kibana-plugin-public.appmountparameters.element.md) | HTMLElement | The container element to render the application into. | +| [onAppLeave](./kibana-plugin-public.appmountparameters.onappleave.md) | (handler: AppLeaveHandler) => void | A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. | diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md new file mode 100644 index 00000000000000..55eb840ce1cf68 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppMountParameters](./kibana-plugin-public.appmountparameters.md) > [onAppLeave](./kibana-plugin-public.appmountparameters.onappleave.md) + +## AppMountParameters.onAppLeave property + +A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page. + +This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url. + +Signature: + +```typescript +onAppLeave: (handler: AppLeaveHandler) => void; +``` + +## Example + + +```ts +// application.tsx +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter, Route } from 'react-router-dom'; + +import { CoreStart, AppMountParams } from 'src/core/public'; +import { MyPluginDepsStart } from './plugin'; + +export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { + const { renderApp, hasUnsavedChanges } = await import('./application'); + onAppLeave(actions => { + if(hasUnsavedChanges()) { + return actions.confirm('Some changes were not saved. Are you sure you want to leave?'); + } + return actions.default(); + }); + return renderApp(params); +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md new file mode 100644 index 00000000000000..d6b22ac2b92178 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +## AppNavLinkStatus enum + +Status of the application's navLink. + +Signature: + +```typescript +export declare enum AppNavLinkStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| default | 0 | The application navLink will be visible if the application's [AppStatus](./kibana-plugin-public.appstatus.md) is set to accessible and hidden if the application status is set to inaccessible. | +| disabled | 2 | The application navLink is visible but inactive and not clickable in the navigation bar. | +| hidden | 3 | The application navLink does not appear in the navigation bar. | +| visible | 1 | The application navLink is visible and clickable in the navigation bar. | + diff --git a/docs/development/core/public/kibana-plugin-public.appstatus.md b/docs/development/core/public/kibana-plugin-public.appstatus.md new file mode 100644 index 00000000000000..23fb7186569dad --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appstatus.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppStatus](./kibana-plugin-public.appstatus.md) + +## AppStatus enum + +Accessibility status of an application. + +Signature: + +```typescript +export declare enum AppStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| accessible | 0 | Application is accessible. | +| inaccessible | 1 | Application is not accessible. | + diff --git a/docs/development/core/public/kibana-plugin-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md new file mode 100644 index 00000000000000..b9260c79cd972b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) + +## AppUpdatableFields type + +Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). + +Signature: + +```typescript +export declare type AppUpdatableFields = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appupdater.md b/docs/development/core/public/kibana-plugin-public.appupdater.md new file mode 100644 index 00000000000000..f1b965cc2fc22f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdater.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdater](./kibana-plugin-public.appupdater.md) + +## AppUpdater type + +Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) + +Signature: + +```typescript +export declare type AppUpdater = (app: AppBase) => Partial | undefined; +``` diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.md index 93ebbe3653ac40..4cb9080222ac54 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.md @@ -24,7 +24,7 @@ export interface ChromeNavLink | [id](./kibana-plugin-public.chromenavlink.id.md) | string | A unique identifier for looking up links. | | [linkToLastSubUrl](./kibana-plugin-public.chromenavlink.linktolastsuburl.md) | boolean | Whether or not the subUrl feature should be enabled. | | [order](./kibana-plugin-public.chromenavlink.order.md) | number | An ordinal used to sort nav links relative to one another for display. | -| [subUrlBase](./kibana-plugin-public.chromenavlink.suburlbase.md) | string | A url base that legacy apps can set to match deep URLs to an applcation. | +| [subUrlBase](./kibana-plugin-public.chromenavlink.suburlbase.md) | string | A url base that legacy apps can set to match deep URLs to an application. | | [title](./kibana-plugin-public.chromenavlink.title.md) | string | The title of the application. | | [tooltip](./kibana-plugin-public.chromenavlink.tooltip.md) | string | A tooltip shown when hovering over an app link. | | [url](./kibana-plugin-public.chromenavlink.url.md) | string | A url that legacy apps can set to deep link into their applications. | diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md index b9d12432a01df9..1b8fb0574cf8be 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md @@ -8,7 +8,7 @@ > > -A url base that legacy apps can set to match deep URLs to an applcation. +A url base that legacy apps can set to match deep URLs to an application. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index e2c2866b57b6b0..64cbdd880fed1a 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -1,137 +1,151 @@ - - -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) - -## kibana-plugin-public package - -The Kibana Core APIs for client-side plugins. - -A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). - -The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. - -## Classes - -| Class | Description | -| --- | --- | -| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | -| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | -| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | - -## Interfaces - -| Interface | Description | -| --- | --- | -| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | -| [AppBase](./kibana-plugin-public.appbase.md) | | -| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | -| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | -| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | -| [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | -| [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | -| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | -| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | -| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | -| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | -| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | -| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | -| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | -| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | -| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | -| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | -| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | -| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | -| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | -| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | -| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | -| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | -| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | -| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | -| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | -| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | -| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | -| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | -| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | -| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | -| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | -| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | -| [I18nStart](./kibana-plugin-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-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | -| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | -| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | -| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | -| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | -| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | -| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | -| [IUiSettingsClient](./kibana-plugin-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-public.iuisettingsclient.md) | -| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | -| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | -| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | -| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | -| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | -| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | -| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | -| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | -| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | -| [SavedObject](./kibana-plugin-public.savedobject.md) | | -| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | -| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | -| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | -| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | -| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | -| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | -| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | -| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | -| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | -| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | -| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | -| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | -| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | -| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | -| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | -| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | -| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | -| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | -| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | -| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | -| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | -| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | - -## Type Aliases - -| Type Alias | Description | -| --- | --- | -| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | -| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | -| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | -| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | -| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | -| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | -| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | -| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | -| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | -| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | -| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | -| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | -| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | -| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | -| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | -| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | -| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | -| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | -| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | -| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | -| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | -| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | -| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | -| [Toast](./kibana-plugin-public.toast.md) | | -| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | -| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | - + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) + +## kibana-plugin-public package + +The Kibana Core APIs for client-side plugins. + +A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). + +The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. + +## Classes + +| Class | Description | +| --- | --- | +| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | +| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | +| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | + +## Enumerations + +| Enumeration | Description | +| --- | --- | +| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | +| [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | Status of the application's navLink. | +| [AppStatus](./kibana-plugin-public.appstatus.md) | Accessibility status of an application. | + +## Interfaces + +| Interface | Description | +| --- | --- | +| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | +| [AppBase](./kibana-plugin-public.appbase.md) | | +| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | +| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | +| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | +| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | +| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | +| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | +| [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | +| [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | +| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | +| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | +| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | +| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | +| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | +| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | +| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | +| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | +| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | +| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | +| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | +| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | +| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | +| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | +| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | +| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | +| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | +| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | +| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | +| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | +| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | +| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | +| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | +| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | +| [I18nStart](./kibana-plugin-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-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | +| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | +| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | +| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | +| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | +| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | +| [IUiSettingsClient](./kibana-plugin-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-public.iuisettingsclient.md) | +| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | +| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | +| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | +| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | +| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | +| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | +| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | +| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | +| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | +| [SavedObject](./kibana-plugin-public.savedobject.md) | | +| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | +| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | +| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | +| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | +| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | +| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | +| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | +| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | +| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | +| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | +| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | +| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | +| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | +| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | +| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | +| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | +| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | +| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | + +## Type Aliases + +| Type Alias | Description | +| --- | --- | +| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | +| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | +| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | +| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | +| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | +| [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). | +| [AppUpdater](./kibana-plugin-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | +| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | +| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | +| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | +| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | +| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | +| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | +| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | +| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | +| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | +| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | +| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | +| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | +| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | +| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | +| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | +| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | +| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | +| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | +| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | +| [Toast](./kibana-plugin-public.toast.md) | | +| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | +| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | + diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.md b/docs/development/core/public/kibana-plugin-public.overlaystart.md index 8b6f11bd819f83..a83044763344ba 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.md @@ -16,6 +16,7 @@ export interface OverlayStart | Property | Type | Description | | --- | --- | --- | | [banners](./kibana-plugin-public.overlaystart.banners.md) | OverlayBannersStart | [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | +| [openConfirm](./kibana-plugin-public.overlaystart.openconfirm.md) | OverlayModalStart['openConfirm'] | | | [openFlyout](./kibana-plugin-public.overlaystart.openflyout.md) | OverlayFlyoutStart['open'] | | | [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | OverlayModalStart['open'] | | diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.openconfirm.md b/docs/development/core/public/kibana-plugin-public.overlaystart.openconfirm.md new file mode 100644 index 00000000000000..543a69e0b33184 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.openconfirm.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [OverlayStart](./kibana-plugin-public.overlaystart.md) > [openConfirm](./kibana-plugin-public.overlaystart.openconfirm.md) + +## OverlayStart.openConfirm property + + +Signature: + +```typescript +openConfirm: OverlayModalStart['openConfirm']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md b/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md index bc9ea71bc23893..d7511a119fc0ff 100644 --- a/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md +++ b/docs/development/core/server/kibana-plugin-server.icustomclusterclient.md @@ -4,7 +4,7 @@ ## ICustomClusterClient type -Represents an Elasticsearch cluster API client created by a plugin.. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). +Represents an Elasticsearch cluster API client created by a plugin. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via `asScoped(...)`). See [ClusterClient](./kibana-plugin-server.clusterclient.md). diff --git a/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md b/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md index 85874f5ec16bae..2e496aa0c46fc7 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md +++ b/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md @@ -10,7 +10,7 @@ Set of helpers used to create `KibanaResponse` to form HTTP response on an incom ```typescript kibanaResponseFactory: { - custom: | Buffer | Stream | { + custom: | { message: string | Error; attributes?: Record | undefined; } | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; @@ -21,9 +21,9 @@ kibanaResponseFactory: { conflict: (options?: ErrorHttpResponseOptions) => KibanaResponse; internalError: (options?: ErrorHttpResponseOptions) => KibanaResponse; customError: (options: CustomHttpResponseOptions) => KibanaResponse; - redirected: (options: RedirectResponseOptions) => KibanaResponse | Buffer | Stream>; - ok: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; - accepted: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + redirected: (options: RedirectResponseOptions) => KibanaResponse>; + ok: (options?: HttpResponseOptions) => KibanaResponse>; + accepted: (options?: HttpResponseOptions) => KibanaResponse>; noContent: (options?: HttpResponseOptions) => KibanaResponse; } ``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 3f01048846fe1a..5e28643843af30 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -177,7 +177,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IBasePath](./kibana-plugin-server.ibasepath.md) | Access or manipulate the Kibana base path[BasePath](./kibana-plugin-server.basepath.md) | | [IClusterClient](./kibana-plugin-server.iclusterclient.md) | Represents an Elasticsearch cluster API client created by the platform. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [IContextProvider](./kibana-plugin-server.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | -| [ICustomClusterClient](./kibana-plugin-server.icustomclusterclient.md) | Represents an Elasticsearch cluster API client created by a plugin.. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | +| [ICustomClusterClient](./kibana-plugin-server.icustomclusterclient.md) | Represents an Elasticsearch cluster API client created by a plugin. It allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)).See [ClusterClient](./kibana-plugin-server.clusterclient.md). | | [IsAuthenticated](./kibana-plugin-server.isauthenticated.md) | Return authentication status for a request. | | [ISavedObjectsRepository](./kibana-plugin-server.isavedobjectsrepository.md) | See [SavedObjectsRepository](./kibana-plugin-server.savedobjectsrepository.md) | | [IScopedClusterClient](./kibana-plugin-server.iscopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API.See [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md). | diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index bbe4b3b68e03b0..0ad27e68f7fe94 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -1,28 +1,185 @@ [[managing-licenses]] -== License Management +== License management -When you install {kib}, it generates a Basic license -with no expiration date. Go to *Management > License Management* to view the -status of your license, start a 30-day trial, or install a new license. +When you install the default distribution of {kib}, you receive a basic license +with no expiration date. For the full list of free features that are included in +the basic license, see https://www.elastic.co/subscriptions[the subscription page]. -To learn more about the available license levels, -see https://www.elastic.co/subscriptions[the subscription page]. +If you want to try out the full set of platinum features, you can activate a +30-day trial license. Go to *Management > License Management* to view the +status of your license, start a trial, or install a new license. -You can activate a 30-day trial license to try out the full set of -https://www.elastic.co/subscriptions[Platinum features], including machine learning, -advanced security, alerting, graph capabilities, and more. +NOTE: You can start a trial only if your cluster has not already activated a +trial license for the current major product version. For example, if you have +already activated a trial for v6.0, you cannot start a new trial until +v7.0. You can, however, contact `info@elastic.co` to request an extended trial +license. -When you activate a new license level, new features will appear in the left sidebar +When you activate a new license level, new features appear in the left sidebar of the *Management* page. [role="screenshot"] image::images/management-license.png[] -At the end of the trial period, the Platinum features operate in a -{stack-ov}/license-expiration.html[degraded mode]. You can revert to a Basic -license, extend the trial, or purchase a subscription. +At the end of the trial period, the platinum features operate in a +<>. You can revert to a basic license, +extend the trial, or purchase a subscription. +TIP: If {security-features} are enabled, before you revert to a basic license or +install a gold or platinum license, you must configure Transport Layer Security +(TLS) in {es}. See {ref}/encrypting-communications.html[Encrypting communications]. +{kib} and the {ref}/start-basic.html[start basic API] provide a list of all of +the features that will no longer be supported if you revert to a basic license. -TIP: If {security-features} are enabled, before you revert to a Basic license or install -a Gold or Platinum license, you must configure Transport Layer Security (TLS) in {es}. -See {ref}/encrypting-communications.html[Encrypting communications]. \ No newline at end of file +[discrete] +[[update-license]] +=== Update your license + +You can update your license at runtime without shutting down your {es} nodes. +License updates take effect immediately. The license is provided as a _JSON_ +file that you install in {kib} or by using the +{ref}/update-license.html[update license API]. + +TIP: If you are using a basic or trial license, {security-features} are disabled +by default. In all other licenses, {security-features} are enabled by default; +you must secure the {stack} or disable the {security-features}. + +[discrete] +[[license-expiration]] +=== License expiration + +Your license is time based and expires at a future date. If you're using +{monitor-features} and your license will expire within 30 days, a license +expiration warning is displayed prominently. Warnings are also displayed on +startup and written to the {es} log starting 30 days from the expiration date. +These error messages tell you when the license expires and what features will be +disabled if you do not update the license. + +IMPORTANT: You should update your license as soon as possible. You are +essentially flying blind when running with an expired license. Access to the +cluster health and stats APIs is critical for monitoring and managing an {es} +cluster. + +[discrete] +[[expiration-beats]] +==== Beats + +* Beats will continue to poll centrally-managed configuration. + +[discrete] +[[expiration-elasticsearch]] +==== {es} + +// Upgrade API is disabled +* The deprecation API is disabled. +* SQL support is disabled. +* Aggregations provided by the analytics plugin are no longer usable. + +[discrete] +[[expiration-watcher]] +==== {stack} {alert-features} + +* The PUT and GET watch APIs are disabled. The DELETE watch API continues to work. +* Watches execute and write to the history. +* The actions of the watches do not execute. + +[discrete] +[[expiration-graph]] +==== {stack} {graph-features} + +* Graph explore APIs are disabled. + +[discrete] +[[expiration-ml]] +==== {stack} {ml-features} + +* APIs to create {anomaly-jobs}, open jobs, send data to jobs, create {dfeeds}, +and start {dfeeds} are disabled. +* All started {dfeeds} are stopped. +* All open {anomaly-jobs} are closed. +* APIs to create and start {dfanalytics-jobs} are disabled. +* Existing {anomaly-job} and {dfanalytics-job} results continue to be available +by using {kib} or APIs. + +[discrete] +[[expiration-monitoring]] +==== {stack} {monitor-features} + +* The agent stops collecting cluster and indices metrics. +* The agent stops automatically cleaning indices older than +`xpack.monitoring.history.duration`. + +[discrete] +[[expiration-security]] +==== {stack} {security-features} + +* Cluster health, cluster stats, and indices stats operations are blocked. +* All data operations (read and write) continue to work. + +Once the license expires, calls to the cluster health, cluster stats, and index +stats APIs fail with a `security_exception` and return a 403 HTTP status code. + +[source,sh] +----------------------------------------------------- +{ + "error": { + "root_cause": [ + { + "type": "security_exception", + "reason": "current license is non-compliant for [security]", + "license.expired.feature": "security" + } + ], + "type": "security_exception", + "reason": "current license is non-compliant for [security]", + "license.expired.feature": "security" + }, + "status": 403 +} +----------------------------------------------------- + +This message enables automatic monitoring systems to easily detect the license +failure without immediately impacting other users. + +[discrete] +[[expiration-logstash]] +==== {ls} pipeline management + +* Cannot create new pipelines or edit or delete existing pipelines from the UI. +* Cannot list or view existing pipelines from the UI. +* Cannot run Logstash instances which are registered to listen to existing pipelines. +//TBD: * Logstash will continue to poll centrally-managed pipelines + +[discrete] +[[expiration-kibana]] +==== {kib} + +* Users can still log into {kib}. +* {kib} works for data exploration and visualization, but some features +are disabled. +* The license management UI is available to easily upgrade your license. See +<> and <>. + +[discrete] +[[expiration-reporting]] +==== {kib} {report-features} + +* Reporting is no longer available in {kib}. +* Report generation URLs stop working. +* Existing reports are no longer accessible. + +[discrete] +[[expiration-rollups]] +==== {rollups-cap} + +* {rollup-jobs-cap} cannot be created or started. +* Existing {rollup-jobs} can be stopped and deleted. +* The get rollup caps and rollup search APIs continue to function. + +[discrete] +[[expiration-transforms]] +==== {transforms-cap} + +* {transforms-cap} cannot be created, previewed, started, or updated. +* Existing {transforms} can be stopped and deleted. +* Existing {transform} results continue to be available. diff --git a/docs/management/snapshot-restore/index.asciidoc b/docs/management/snapshot-restore/index.asciidoc index f19aaa122675e7..dc722c24af76ca 100644 --- a/docs/management/snapshot-restore/index.asciidoc +++ b/docs/management/snapshot-restore/index.asciidoc @@ -21,7 +21,7 @@ With this UI, you can: image:management/snapshot-restore/images/snapshot_list.png["Snapshot list"] Before using this feature, you should be familiar with how snapshots work. -{ref}/modules-snapshots.html[Snapshot and Restore] is a good source for +{ref}/snapshot-restore.html[Snapshot and Restore] is a good source for more detailed information. [float] @@ -35,9 +35,9 @@ registering one. {kib} supports three repository types out of the box: shared file system, read-only URL, and source-only. For more information on these repositories and their settings, -see {ref}/modules-snapshots.html#snapshots-repositories[Repositories]. +see {ref}/snapshots-register-repository.html[Repositories]. To use other repositories, such as S3, see -{ref}/modules-snapshots.html#_repository_plugins[Repository plugins]. +{ref}/snapshots-register-repository.html#snapshots-repository-plugins[Repository plugins]. Once you create a repository, it is listed in the *Repositories* @@ -61,7 +61,7 @@ into each snapshot for further investigation. image:management/snapshot-restore/images/snapshot_details.png["Snapshot details"] If you don’t have any snapshots, you can create them from the {kib} <>. The -{ref}//modules-snapshots.html#snapshots-take-snapshot[snapshot API] +{ref}/snapshots-take-snapshot.html[snapshot API] takes the current state and data in your index or cluster, and then saves it to a shared repository. @@ -162,7 +162,7 @@ Ready to try *Snapshot and Restore*? In this tutorial, you'll learn to: This example shows you how to register a shared file system repository and store snapshots. Before you begin, you must register the location of the repository in the -{ref}/modules-snapshots.html#_shared_file_system_repository[path.repo] setting on +{ref}/snapshots-register-repository.html#snapshots-filesystem-repository[path.repo] setting on your master and data nodes. You can do this in one of two ways: * Edit your `elasticsearch.yml` to include the `path.repo` setting. @@ -197,7 +197,7 @@ The repository currently doesn’t have any snapshots. [float] ==== Add a snapshot to the repository -Use the {ref}//modules-snapshots.html#snapshots-take-snapshot[snapshot API] to create a snapshot. +Use the {ref}/snapshots-take-snapshot.html[snapshot API] to create a snapshot. . Go to *Dev Tools > Console*. . Create the snapshot: @@ -206,7 +206,7 @@ Use the {ref}//modules-snapshots.html#snapshots-take-snapshot[snapshot API] to c PUT /_snapshot/my_backup/2019-04-25_snapshot?wait_for_completion=true + In this example, the snapshot name is `2019-04-25_snapshot`. You can also -use {ref}//date-math-index-names.html[date math expression] for the snapshot name. +use {ref}/date-math-index-names.html[date math expression] for the snapshot name. + [role="screenshot"] image:management/snapshot-restore/images/create_snapshot.png["Create snapshot"] diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 8d28b55a6502fb..a6eeffec51cb02 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -5,9 +5,23 @@ APM settings ++++ -You do not need to configure any settings to use APM. It is enabled by default. -If you'd like to change any of the default values, -copy and paste the relevant settings below into your `kibana.yml` configuration file. +You do not need to configure any settings to use the APM app. It is enabled by default. + +[float] +[[apm-indices-settings-kb]] +==== APM Indices + +// This content is reused in the APM app documentation. +// Any changes made in this file will be seen there as well. +// tag::apm-indices-settings[] + +Index defaults can be changed in Kibana. Navigate to *APM* > *Settings* > *Indices*. +Index settings in the APM app take precedence over those set in `kibana.yml`. + +[role="screenshot"] +image::settings/images/apm-settings.png[APM app settings in Kibana] + +// end::apm-indices-settings[] [float] [[general-apm-settings-kb]] @@ -17,6 +31,9 @@ copy and paste the relevant settings below into your `kibana.yml` configuration // Any changes made in this file will be seen there as well. // tag::general-apm-settings[] +If you'd like to change any of the default values, +copy and paste the relevant settings below into your `kibana.yml` configuration file. + xpack.apm.enabled:: Set to `false` to disabled the APM plugin {kib}. Defaults to `true`. diff --git a/docs/settings/images/apm-settings.png b/docs/settings/images/apm-settings.png new file mode 100644 index 00000000000000..876f135da93564 Binary files /dev/null and b/docs/settings/images/apm-settings.png differ diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index a754f91e9f22a1..a9fa2bd18d3154 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -54,6 +54,14 @@ The protocol for accessing Kibana, typically `http` or `https`. `xpack.reporting.kibanaServer.hostname`:: The hostname for accessing {kib}, if different from the `server.host` value. +[NOTE] +============ +Reporting authenticates requests on the Kibana page only when the hostname matches the +`xpack.reporting.kibanaServer.hostname` setting. Therefore Reporting would fail if the +set value redirects to another server. For that reason, `"0"` is an invalid setting +because, in the Reporting browser, it becomes an automatic redirect to `"0.0.0.0"`. +============ + [float] [[reporting-job-queue-settings]] diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index d6dd4378da1b7c..16d68a7759f776 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -45,7 +45,7 @@ if this setting isn't the same for all instances of {kib}. `xpack.security.secureCookies`:: Sets the `secure` flag of the session cookie. The default value is `false`. It -is set to `true` if `server.ssl.certificate` and `server.ssl.key` are set. Set +is automatically set to `true` if `server.ssl.enabled` is set to `true`. Set this to `true` if SSL is configured outside of {kib} (for example, you are routing requests through a load balancer or proxy). diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index c98df6ca78356b..535ad169782170 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -82,31 +82,52 @@ Elasticsearch nodes on startup. `elasticsearch.sniffOnConnectionFault:`:: *Default: false* Update the list of Elasticsearch nodes immediately following a connection fault. -`elasticsearch.ssl.alwaysPresentCertificate:`:: *Default: false* Controls -whether to always present the certificate specified by -`elasticsearch.ssl.certificate` when requested. This applies to all requests to -Elasticsearch, including requests that are proxied for end-users. Setting this -to `true` when Elasticsearch is using certificates to authenticate users can -lead to proxied requests for end-users being executed as the identity tied to -the configured certificate. - -`elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:`:: Optional -settings that provide the paths to the PEM-format SSL certificate and key files. -These files are used to verify the identity of Kibana to Elasticsearch and are -required when `xpack.security.http.ssl.client_authentication` in Elasticsearch is -set to `required`. - -`elasticsearch.ssl.certificateAuthorities:`:: Optional setting that enables you -to specify a list of paths to the PEM file for the certificate authority for -your Elasticsearch instance. - -`elasticsearch.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt -the private key. This value is optional as the key may not be encrypted. - -`elasticsearch.ssl.verificationMode:`:: *Default: full* Controls the -verification of certificates presented by Elasticsearch. Valid values are `none`, -`certificate`, and `full`. `full` performs hostname verification, and -`certificate` does not. +`elasticsearch.ssl.alwaysPresentCertificate:`:: *Default: false* Controls whether to always present the certificate specified by +`elasticsearch.ssl.certificate` or `elasticsearch.ssl.keystore.path` when requested. This setting applies to all requests to Elasticsearch, +including requests that are proxied for end users. Setting this to `true` when Elasticsearch is using certificates to authenticate users can +lead to proxied requests for end users being executed as the identity tied to the configured certificate. + +`elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:`:: Paths to a PEM-encoded X.509 certificate and its private key, respectively. +When `xpack.security.http.ssl.client_authentication` in Elasticsearch is set to `required` or `optional`, the certificate and key are used +to prove Kibana's identity when it makes an outbound request to your Elasticsearch cluster. ++ +-- +NOTE: These settings cannot be used in conjunction with `elasticsearch.ssl.keystore.path`. +-- + +`elasticsearch.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificates. These certificates may consist of a root +certificate authority (CA), and one or more intermediate CAs, which make up a trusted certificate chain for Kibana. This chain is used to +establish trust when Kibana creates an SSL connection with your Elasticsearch cluster. In addition to this setting, trusted certificates may +be specified via `elasticsearch.ssl.keystore.path` and/or `elasticsearch.ssl.truststore.path`. + +`elasticsearch.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt the private key that is specified via +`elasticsearch.ssl.key`. This value is optional, as the key may not be encrypted. + +`elasticsearch.ssl.keystore.path:`:: Path to a PKCS #12 file that contains an X.509 certificate with its private key. When +`xpack.security.http.ssl.client_authentication` in Elasticsearch is set to `required` or `optional`, the certificate and key are used to +prove Kibana's identity when it makes an outbound request to your Elasticsearch cluster. If the file contains any additional certificates, +those will be used as a trusted certificate chain for your Elasticsearch cluster. This chain is used to establish trust when Kibana creates +an SSL connection with your Elasticsearch cluster. In addition to this setting, trusted certificates may be specified via +`elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.truststore.path`. ++ +-- +NOTE: This setting cannot be used in conjunction with `elasticsearch.ssl.certificate` or `elasticsearch.ssl.key`. +-- + +`elasticsearch.ssl.keystore.password:`:: The password that will be used to decrypt the key store and its private key. If your key store has +no password, leave this unset. If your key store has an empty password, set this to `""`. + +`elasticsearch.ssl.truststore.path:`:: Path to a PKCS #12 trust store that contains one or more X.509 certificates. This may consist of a +root certificate authority (CA) and one or more intermediate CAs, which make up a trusted certificate chain for your Elasticsearch cluster. +This chain is used to establish trust when Kibana creates an SSL connection with your Elasticsearch cluster. In addition to this setting, +trusted certificates may be specified via `elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.keystore.path`. + +`elasticsearch.ssl.truststore.password:`:: The password that will be used to decrypt the trust store. If your trust store has no password, +leave this unset. If your trust store has an empty password, set this to `""`. + +`elasticsearch.ssl.verificationMode:`:: *Default: full* Controls the verification of certificates presented by Elasticsearch. Valid values +are `none`, `certificate`, and `full`. `full` performs hostname verification and `certificate` does not. This setting is used only when +traffic to Elasticsearch is encrypted, which is specified by using the HTTPS protocol in `elasticsearch.hosts`. `elasticsearch.startupTimeout:`:: *Default: 5000* Time in milliseconds to wait for Elasticsearch at Kibana startup before retrying. @@ -325,11 +346,19 @@ default is `true`. `server.socketTimeout:`:: *Default: "120000"* The number of milliseconds to wait before closing an inactive socket. -`server.ssl.certificate:` and `server.ssl.key:`:: Paths to the PEM-format SSL -certificate and SSL key files, respectively. +`server.ssl.certificate:` and `server.ssl.key:`:: Paths to a PEM-encoded X.509 certificate and its private key, respectively. These are used +when enabling SSL for inbound requests from web browsers to the Kibana server. ++ +-- +NOTE: These settings cannot be used in conjunction with `server.ssl.keystore.path`. +-- -`server.ssl.certificateAuthorities:`:: List of paths to PEM encoded certificate -files that should be trusted. +`server.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificates. These certificates may consist of a root +certificate authority (CA) and one or more intermediate CAs, which make up a trusted certificate chain for Kibana. This chain is used when a +web browser creates an SSL connection with the Kibana server; the certificate chain is sent to the browser along with the end-entity +certificate to establish trust. This chain is also used to determine whether client certificates should be trusted when PKI authentication +is enabled. In addition to this setting, trusted certificates may be specified via `server.ssl.keystore.path` and/or +`server.ssl.truststore.path`. `server.ssl.cipherSuites:`:: *Default: ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA*. Details on the format, and the valid options, are available via the @@ -339,12 +368,36 @@ https://www.openssl.org/docs/man1.0.2/apps/ciphers.html#CIPHER-LIST-FORMAT[OpenS connections. Valid values are `required`, `optional`, and `none`. `required` forces a client to present a certificate, while `optional` requests a client certificate but the client is not required to present one. -`server.ssl.enabled:`:: *Default: "false"* Enables SSL for outgoing requests -from the Kibana server to the browser. When set to `true`, -`server.ssl.certificate` and `server.ssl.key` are required. +`server.ssl.enabled:`:: *Default: "false"* Enables SSL for inbound requests from the browser to the Kibana server. When set to `true`, a +certificate and private key must be provided. These can be specified via `server.ssl.keystore.path` or the combination of +`server.ssl.certificate` and `server.ssl.key`. + +`server.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt the private key that is specified via `server.ssl.key`. This value +is optional, as the key may not be encrypted. + +`server.ssl.keystore.path:`:: Path to a PKCS #12 file that contains an X.509 certificate with its private key. These are used when enabling +SSL for inbound requests from web browsers to the Kibana server. If the file contains any additional certificates, those will be used as a +trusted certificate chain for Kibana. This chain is used when a web browser creates an SSL connection with the Kibana server; the +certificate chain is sent to the browser along with the end-entity certificate to establish trust. This chain is also used to determine +whether client certificates should be trusted when PKI authentication is enabled. In addition to this setting, trusted certificates may be +specified via `server.ssl.certificateAuthorities` and/or `server.ssl.truststore.path`. ++ +-- +NOTE: This setting cannot be used in conjunction with `server.ssl.certificate` or `server.ssl.key`. +-- + +`server.ssl.keystore.password:`:: The password that will be used to decrypt the key store and its private key. If your key store has no +password, leave this unset. If your key store has an empty password, set this to `""`. + +`server.ssl.truststore.path:`:: Path to a PKCS #12 trust store that contains one or more X.509 certificates. These certificates may consist +of a root certificate authority (CA) and one or more intermediate CAs, which make up a trusted certificate chain for Kibana. This chain is +used when a web browser creates an SSL connection with the Kibana server; the certificate chain is sent to the browser along with the +end-entity certificate to establish trust. This chain is also used to determine whether client certificates should be trusted when PKI +authentication is enabled. In addition to this setting, trusted certificates may be specified via `server.ssl.certificateAuthorities` and/or +`server.ssl.keystore.path`. -`server.ssl.keyPassphrase:`:: The passphrase that will be used to decrypt the -private key. This value is optional as the key may not be encrypted. +`server.ssl.truststore.password:`:: The password that will be used to decrypt the trust store. If your trust store has no password, leave +this unset. If your trust store has an empty password, set this to `""`. `server.ssl.redirectHttpFromPort:`:: Kibana will bind to this port and redirect all http requests to https over the port configured as `server.port`. diff --git a/docs/user/monitoring/monitoring-kibana.asciidoc b/docs/user/monitoring/monitoring-kibana.asciidoc index d7af0d5c420a14..b5d263aed8346a 100644 --- a/docs/user/monitoring/monitoring-kibana.asciidoc +++ b/docs/user/monitoring/monitoring-kibana.asciidoc @@ -96,7 +96,8 @@ used when {kib} sends monitoring data to the production cluster. .. Configure {kib} to encrypt communications between the {kib} server and the production cluster. This set up involves generating a server certificate and setting `server.ssl.*` and `elasticsearch.ssl.certificateAuthorities` settings -in the `kibana.yml` file on the {kib} server. For example: +in the `kibana.yml` file on the {kib} server. For example, using a PEM-formatted +certificate and private key: + -- [source,yaml] @@ -105,14 +106,19 @@ server.ssl.key: /path/to/your/server.key server.ssl.certificate: /path/to/your/server.crt -------------------------------------------------------------------------------- -If you are using your own certificate authority to sign certificates, specify -the location of the PEM file in the `kibana.yml` file: +If you are using your own certificate authority (CA) to sign certificates, +specify the location of the PEM file in the `kibana.yml` file: [source,yaml] -------------------------------------------------------------------------------- elasticsearch.ssl.certificateAuthorities: /path/to/your/cacert.pem -------------------------------------------------------------------------------- +NOTE: Alternatively, the PKCS #12 format can be used for the Kibana certificate +and key, along with any included CA certificates, by setting +`server.ssl.keystore.path`. If your CA certificate chain is in a separate trust +store, you can also use `server.ssl.truststore.path`. + For more information, see <>. -- diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 2e2aaf688e8b68..05aabfc343be94 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -67,6 +67,9 @@ server.ssl.clientAuthentication: required xpack.security.authc.providers: [pki] -------------------------------------------------------------------------------- +NOTE: Trusted CAs can also be specified in a PKCS #12 keystore bundled with your Kibana server certificate/key using +`server.ssl.keystore.path` or in a separate trust store using `server.ssl.truststore.path`. + PKI support in {kib} is designed to be the primary (or sole) authentication method for users of that {kib} instance. However, you can configure both PKI and Basic authentication for the same {kib} instance: [source,yaml] diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index eab3833b3f5ae3..e3d6e0d97c73a4 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -37,4 +37,5 @@ cause Kibana's authorization to behave unexpectedly. include::authorization/index.asciidoc[] include::authorization/kibana-privileges.asciidoc[] include::api-keys/index.asciidoc[] +include::role-mappings/index.asciidoc[] include::rbac_tutorial.asciidoc[] diff --git a/docs/user/security/role-mappings/images/role-mappings-create-step-1.png b/docs/user/security/role-mappings/images/role-mappings-create-step-1.png new file mode 100644 index 00000000000000..2b4ad16459529c Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-create-step-1.png differ diff --git a/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif b/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif new file mode 100644 index 00000000000000..0a10126ea3cce3 Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif differ diff --git a/docs/user/security/role-mappings/images/role-mappings-grid.png b/docs/user/security/role-mappings/images/role-mappings-grid.png new file mode 100644 index 00000000000000..96c9ee8e4cd95e Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-grid.png differ diff --git a/docs/user/security/role-mappings/index.asciidoc b/docs/user/security/role-mappings/index.asciidoc new file mode 100644 index 00000000000000..01028ab4d59e08 --- /dev/null +++ b/docs/user/security/role-mappings/index.asciidoc @@ -0,0 +1,51 @@ +[role="xpack"] +[[role-mappings]] +=== Role mappings + +Role mappings allow you to describe which roles to assign to your users +using a set of rules. Role mappings are required when authenticating via +an external identity provider, such as Active Directory, Kerberos, PKI, OIDC, +or SAML. + +Role mappings have no effect for users inside the `native` or `file` realms. + +To manage your role mappings, use *Management > Security > Role Mappings*. + +With *Role mappings*, you can: + +* View your configured role mappings +* Create/Edit/Delete role mappings + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-grid.png["Role mappings"] + + +[float] +=== Create a role mapping + +To create a role mapping, navigate to *Management > Security > Role Mappings*, and click **Create role mapping**. +Give your role mapping a unique name, and choose which roles you wish to assign to your users. +If you need more flexibility, you can use {ref}/security-api-put-role-mapping.html#_role_templates[role templates] instead. + +Next, define the rules describing which users should receive the roles you defined. Rules can optionally grouped and nested, allowing for sophisticated logic to suite complex requirements. +View the {ref}/role-mapping-resources.html[role mapping resources for an overview of the allowed rule types]. + + +[float] +=== Example + +Let's create a `sales-users` role mapping, which assigns a `sales` role to users whose username +starts with `sls_`, *or* belongs to the `executive` group. + +First, we give the role mapping a name, and assign the `sales` role: + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-create-step-1.png["Create role mapping, step 1"] + +Next, we define the two rules, making sure to set the group to *Any are true*: + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-create-step-2.gif["Create role mapping, step 2"] + +Click *Save role mapping* once you're finished. + diff --git a/docs/user/security/securing-communications/index.asciidoc b/docs/user/security/securing-communications/index.asciidoc index 6917a48909c7b0..b370c35905bce4 100644 --- a/docs/user/security/securing-communications/index.asciidoc +++ b/docs/user/security/securing-communications/index.asciidoc @@ -4,121 +4,115 @@ Encrypting communications ++++ -{kib} supports Transport Layer Security (TLS/SSL) encryption for client -requests. -//TBD: It is unclear what "client requests" are in this context. Is it just -// communication between the browser and the Kibana server or are we talking -// about other types of clients connecting to the Kibana server? - -If you are using {security} or a proxy that provides an HTTPS endpoint for {es}, -you can configure {kib} to access {es} via HTTPS. Thus, communications between -{kib} and {es} are also encrypted. - -. Configure {kib} to encrypt communications between the browser and the {kib} -server: +{kib} supports Transport Layer Security (TLS/SSL) encryption for all forms of data-in-transit. Browsers send traffic to {kib} and {kib} +sends traffic to {es}. These communications are configured separately. + +[[configuring-tls-browser-kib]] +==== Encrypting traffic between the browser and {kib} + +NOTE: You do not need to enable {security-features} for this type of encryption. + +. Obtain a server certificate and private key for {kib}. + -- -NOTE: You do not need to enable {security} for this type of encryption. +{kib} supports certificates/keys in both PKCS #12 key stores and PEM format. + +When you obtain a certificate, you must do at least one of the following: + +.. Set the certificate's `subjectAltName` to the hostname, fully-qualified domain name (FQDN), or IP address of the {kib} server. + +.. Set the certificate's Common Name (CN) to the {kib} server's hostname or FQDN. Using the server's IP address as the CN does not work. + +You may choose to generate a certificate and private key using {ref}/certutil.html[the {es} certutil tool]. If you already used certutil to +generate a certificate authority (CA), you would generate a certificate/key for Kibana like so (using the `--dns` param to set the +`subjectAltName`): + +[source,sh] +-------------------------------------------------------------------------------- +bin/elasticsearch-certutil cert --ca elastic-stack-ca.p12 --name kibana --dns localhost +-------------------------------------------------------------------------------- + +This will generate a certificate and private key in a PKCS #12 keystore named `kibana.p12`. -- -.. Generate a server certificate for {kib}. +. Enable TLS/SSL in `kibana.yml`: + -- -//TBD: Can we provide more information about how they generate the certificate? -//Would they be able to use something like the elasticsearch-certutil command? -You must either set the certificate's -`subjectAltName` to the hostname, fully-qualified domain name (FQDN), or IP -address of the {kib} server, or set the CN to the {kib} server's hostname -or FQDN. Using the server's IP address as the CN does not work. +[source,yaml] +-------------------------------------------------------------------------------- +server.ssl.enabled: true +-------------------------------------------------------------------------------- -- -.. Set the `server.ssl.enabled`, `server.ssl.key`, and `server.ssl.certificate` -properties in `kibana.yml`: +. Specify your server certificate and private key in `kibana.yml`: + -- +If your certificate and private key are in a PKCS #12 keystore, specify it like so: + [source,yaml] -------------------------------------------------------------------------------- -server.ssl.enabled: true -server.ssl.key: /path/to/your/server.key -server.ssl.certificate: /path/to/your/server.crt +server.ssl.keystore.path: "/path/to/your/keystore.p12" +server.ssl.keystore.password: "optional decryption password" +-------------------------------------------------------------------------------- + +Otherwise, if your certificate/key are in PEM format, specify them like so: + +[source,yaml] +-------------------------------------------------------------------------------- +server.ssl.certificate: "/path/to/your/server.crt" +server.ssl.key: "/path/to/your/server.key" +server.ssl.keyPassphrase: "optional decryption password" -------------------------------------------------------------------------------- After making these changes, you must always access {kib} via HTTPS. For example, https://localhost:5601. -// TBD: The reference information for server.ssl.enabled says it "enables SSL for -// outgoing requests from the Kibana server to the browser". Do we need to -// reiterate here that only one side of the communications is encrypted? - For more information, see <>. -- -. Configure {kib} to connect to {es} via HTTPS: -+ --- +[[configuring-tls-kib-es]] +==== Encrypting traffic between {kib} and {es} + NOTE: To perform this step, you must {ref}/configuring-security.html[enable the {es} {security-features}] or you must have a proxy that provides an HTTPS endpoint for {es}. --- - -.. Specify the HTTPS protocol in the `elasticsearch.hosts` setting in the {kib} -configuration file, `kibana.yml`: +. Specify the HTTPS URL in the `elasticsearch.hosts` setting in the {kib} configuration file, `kibana.yml`: + -- [source,yaml] -------------------------------------------------------------------------------- elasticsearch.hosts: ["https://.com:9200"] -------------------------------------------------------------------------------- --- - -.. If you are using your own CA to sign certificates for {es}, set the -`elasticsearch.ssl.certificateAuthorities` setting in `kibana.yml` to specify -the location of the PEM file. -+ --- -[source,yaml] --------------------------------------------------------------------------------- -elasticsearch.ssl.certificateAuthorities: /path/to/your/cacert.pem --------------------------------------------------------------------------------- -Setting the `certificateAuthorities` property lets you use the default -`verificationMode` option of `full`. -//TBD: Is this still true? It isn't mentioned in https://www.elastic.co/guide/en/kibana/master/settings.html +Using the HTTPS protocol results in a default `elasticsearch.ssl.verificationMode` option of `full`, which utilizes hostname verification. For more information, see <>. -- -. (Optional) If the Elastic {monitor-features} are enabled, configure {kib} to -connect to the {es} monitoring cluster via HTTPS: +. Specify the {es} cluster's CA certificate chain in `kibana.yml`: + -- -NOTE: To perform this step, you must -{ref}/configuring-security.html[enable the {es} {security-features}] or you -must have a proxy that provides an HTTPS endpoint for {es}. --- +If you are using your own CA to sign certificates for {es}, then you need to specify the CA certificate chain in {kib} to properly establish +trust in TLS connections. If your CA certificate chain is contained in a PKCS #12 trust store, specify it like so: -.. Specify the HTTPS URL in the `xpack.monitoring.elasticsearch.hosts` setting in -the {kib} configuration file, `kibana.yml` -+ --- [source,yaml] -------------------------------------------------------------------------------- -xpack.monitoring.elasticsearch.hosts: ["https://:9200"] +elasticsearch.ssl.truststore.path: "/path/to/your/truststore.p12" +elasticsearch.ssl.truststore.password: "optional decryption password" -------------------------------------------------------------------------------- --- -.. Specify the `xpack.monitoring.elasticsearch.ssl.*` settings in the -`kibana.yml` file. -+ --- -For example, if you are using your own certificate authority to sign -certificates, specify the location of the PEM file in the `kibana.yml` file: +Otherwise, if your CA certificate chain is in PEM format, specify each certificate like so: [source,yaml] -------------------------------------------------------------------------------- -xpack.monitoring.elasticsearch.ssl.certificateAuthorities: /path/to/your/cacert.pem +elasticsearch.ssl.certificateAuthorities: ["/path/to/your/cacert1.pem", "/path/to/your/cacert2.pem"] -------------------------------------------------------------------------------- + -- + +. (Optional) If the Elastic {monitor-features} are enabled, configure {kib} to connect to the {es} monitoring cluster via HTTPS. The steps +are the same as above, but each setting is prefixed by `xpack.monitoring.`. For example, `xpack.monitoring.elasticsearch.hosts`, +`xpack.monitoring.elasticsearch.ssl.truststore.path`, etc. diff --git a/docs/visualize/visualize_rollup_data.asciidoc b/docs/visualize/visualize_rollup_data.asciidoc index 110533589cab9c..481cbc6e394182 100644 --- a/docs/visualize/visualize_rollup_data.asciidoc +++ b/docs/visualize/visualize_rollup_data.asciidoc @@ -6,7 +6,7 @@ beta[] You can visualize your rolled up data in a variety of charts, tables, maps, and more. Most visualizations support rolled up data, with the exception of -Timelion, TSVB, and Vega visualizations. +Timelion and Vega visualizations. To get started, go to *Management > Kibana > Index patterns.* If a rollup index is detected in the cluster, *Create index pattern* diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json new file mode 100644 index 00000000000000..9114a414a4da39 --- /dev/null +++ b/examples/state_containers_examples/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "stateContainersExamples", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["state_containers_examples"], + "server": false, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [] +} diff --git a/examples/state_containers_examples/package.json b/examples/state_containers_examples/package.json new file mode 100644 index 00000000000000..b309494a366628 --- /dev/null +++ b/examples/state_containers_examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "state_containers_examples", + "version": "1.0.0", + "main": "target/examples/state_containers_examples", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/examples/state_containers_examples/public/app.tsx b/examples/state_containers_examples/public/app.tsx new file mode 100644 index 00000000000000..319680d07f9bc8 --- /dev/null +++ b/examples/state_containers_examples/public/app.tsx @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AppMountParameters } from 'kibana/public'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import { createHashHistory, createBrowserHistory } from 'history'; +import { TodoAppPage } from './todo'; + +export interface AppOptions { + appInstanceId: string; + appTitle: string; + historyType: History; +} + +export enum History { + Browser, + Hash, +} + +export const renderApp = ( + { appBasePath, element }: AppMountParameters, + { appInstanceId, appTitle, historyType }: AppOptions +) => { + const history = + historyType === History.Browser + ? createBrowserHistory({ basename: appBasePath }) + : createHashHistory(); + ReactDOM.render( + { + const stripTrailingSlash = (path: string) => + path.charAt(path.length - 1) === '/' ? path.substr(0, path.length - 1) : path; + const currentAppUrl = stripTrailingSlash(history.createHref(history.location)); + if (historyType === History.Browser) { + // browser history + const basePath = stripTrailingSlash(appBasePath); + return currentAppUrl === basePath && !history.location.search && !history.location.hash; + } else { + // hashed history + return currentAppUrl === '#' && !history.location.search; + } + }} + />, + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/state_containers_examples/public/index.ts b/examples/state_containers_examples/public/index.ts new file mode 100644 index 00000000000000..bc7ad78574ddb8 --- /dev/null +++ b/examples/state_containers_examples/public/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { StateContainersExamplesPlugin } from './plugin'; + +export const plugin = () => new StateContainersExamplesPlugin(); diff --git a/examples/state_containers_examples/public/plugin.ts b/examples/state_containers_examples/public/plugin.ts new file mode 100644 index 00000000000000..beb7b93dbc5b66 --- /dev/null +++ b/examples/state_containers_examples/public/plugin.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AppMountParameters, CoreSetup, Plugin } from 'kibana/public'; + +export class StateContainersExamplesPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'state-containers-example-browser-history', + title: 'State containers example - browser history routing', + async mount(params: AppMountParameters) { + const { renderApp, History } = await import('./app'); + return renderApp(params, { + appInstanceId: '1', + appTitle: 'Routing with browser history', + historyType: History.Browser, + }); + }, + }); + core.application.register({ + id: 'state-containers-example-hash-history', + title: 'State containers example - hash history routing', + async mount(params: AppMountParameters) { + const { renderApp, History } = await import('./app'); + return renderApp(params, { + appInstanceId: '2', + appTitle: 'Routing with hash history', + historyType: History.Hash, + }); + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx new file mode 100644 index 00000000000000..84defb4a91e3f1 --- /dev/null +++ b/examples/state_containers_examples/public/todo.tsx @@ -0,0 +1,327 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect } from 'react'; +import { Link, Route, Router, Switch, useLocation } from 'react-router-dom'; +import { History } from 'history'; +import { + EuiButton, + EuiCheckbox, + EuiFieldText, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; +import { + BaseStateContainer, + INullableBaseStateContainer, + createKbnUrlStateStorage, + createSessionStorageStateStorage, + createStateContainer, + createStateContainerReactHelpers, + PureTransition, + syncStates, + getStateFromKbnUrl, +} from '../../../src/plugins/kibana_utils/public'; +import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; +import { + defaultState, + pureTransitions, + TodoActions, + TodoState, +} from '../../../src/plugins/kibana_utils/demos/state_containers/todomvc'; + +interface GlobalState { + text: string; +} +interface GlobalStateAction { + setText: PureTransition; +} +const defaultGlobalState: GlobalState = { text: '' }; +const globalStateContainer = createStateContainer( + defaultGlobalState, + { + setText: state => text => ({ ...state, text }), + } +); + +const GlobalStateHelpers = createStateContainerReactHelpers(); + +const container = createStateContainer(defaultState, pureTransitions); +const { Provider, connect, useTransitions, useState } = createStateContainerReactHelpers< + typeof container +>(); + +interface TodoAppProps { + filter: 'completed' | 'not-completed' | null; +} + +const TodoApp: React.FC = ({ filter }) => { + const { setText } = GlobalStateHelpers.useTransitions(); + const { text } = GlobalStateHelpers.useState(); + const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); + const todos = useState(); + const filteredTodos = todos.filter(todo => { + if (!filter) return true; + if (filter === 'completed') return todo.completed; + if (filter === 'not-completed') return !todo.completed; + return true; + }); + const location = useLocation(); + return ( + <> +
+ + + All + + + + + Completed + + + + + Not Completed + + +
+
    + {filteredTodos.map(todo => ( +
  • + { + editTodo({ + ...todo, + completed: e.target.checked, + }); + }} + label={todo.text} + /> + { + deleteTodo(todo.id); + }} + > + Delete + +
  • + ))} +
+
{ + const inputRef = (e.target as HTMLFormElement).elements.namedItem( + 'newTodo' + ) as HTMLInputElement; + if (!inputRef || !inputRef.value) return; + addTodo({ + text: inputRef.value, + completed: false, + id: todos.map(todo => todo.id).reduce((a, b) => Math.max(a, b), 0) + 1, + }); + inputRef.value = ''; + e.preventDefault(); + }} + > + + +
+ + setText(e.target.value)} /> +
+ + ); +}; + +const TodoAppConnected = GlobalStateHelpers.connect(() => ({}))( + connect(() => ({}))(TodoApp) +); + +export const TodoAppPage: React.FC<{ + history: History; + appInstanceId: string; + appTitle: string; + appBasePath: string; + isInitialRoute: () => boolean; +}> = props => { + const initialAppUrl = React.useRef(window.location.href); + const [useHashedUrl, setUseHashedUrl] = React.useState(false); + + /** + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage and tries to restore it on "componentDidMount" + */ + useUrlTracker(`lastUrlTracker:${props.appInstanceId}`, props.history, urlToRestore => { + // shouldRestoreUrl: + // App decides if it should restore url or not + // In this specific case, restore only if navigated to initial route + if (props.isInitialRoute()) { + // navigated to the base path, so should restore the url + return true; + } else { + // navigated to specific route, so should not restore the url + return false; + } + }); + + useEffect(() => { + // have to sync with history passed to react-router + // history v5 will be singleton and this will not be needed + const kbnUrlStateStorage = createKbnUrlStateStorage({ + useHash: useHashedUrl, + history: props.history, + }); + + const sessionStorageStateStorage = createSessionStorageStateStorage(); + + /** + * Restoring global state: + * State restoration similar to what GlobalState in legacy world did + * It restores state both from url and from session storage + */ + const globalStateKey = `_g`; + const globalStateFromInitialUrl = getStateFromKbnUrl( + globalStateKey, + initialAppUrl.current + ); + const globalStateFromCurrentUrl = kbnUrlStateStorage.get(globalStateKey); + const globalStateFromSessionStorage = sessionStorageStateStorage.get( + globalStateKey + ); + + const initialGlobalState: GlobalState = { + ...defaultGlobalState, + ...globalStateFromCurrentUrl, + ...globalStateFromSessionStorage, + ...globalStateFromInitialUrl, + }; + globalStateContainer.set(initialGlobalState); + kbnUrlStateStorage.set(globalStateKey, initialGlobalState, { replace: true }); + sessionStorageStateStorage.set(globalStateKey, initialGlobalState); + + /** + * Restoring app local state: + * State restoration similar to what AppState in legacy world did + * It restores state both from url + */ + const appStateKey = `_todo-${props.appInstanceId}`; + const initialAppState: TodoState = + getStateFromKbnUrl(appStateKey, initialAppUrl.current) || + kbnUrlStateStorage.get(appStateKey) || + defaultState; + container.set(initialAppState); + kbnUrlStateStorage.set(appStateKey, initialAppState, { replace: true }); + + // start syncing only when made sure, that state in synced + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: appStateKey, + stateStorage: kbnUrlStateStorage, + }, + { + stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), + storageKey: globalStateKey, + stateStorage: kbnUrlStateStorage, + }, + { + stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), + storageKey: globalStateKey, + stateStorage: sessionStorageStateStorage, + }, + ]); + + start(); + + return () => { + stop(); + + // reset state containers + container.set(defaultState); + globalStateContainer.set(defaultGlobalState); + }; + }, [props.appInstanceId, props.history, useHashedUrl]); + + return ( + + + + + + + +

+ State sync example. Instance: ${props.appInstanceId}. {props.appTitle} +

+
+ setUseHashedUrl(!useHashedUrl)}> + {useHashedUrl ? 'Use Expanded State' : 'Use Hashed State'} + +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +}; + +function withDefaultState( + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + if (Array.isArray(defaultState)) { + stateContainer.set(state || defaultState); + } else { + stateContainer.set({ + ...defaultState, + ...state, + }); + } + }, + }; +} diff --git a/examples/state_containers_examples/tsconfig.json b/examples/state_containers_examples/tsconfig.json new file mode 100644 index 00000000000000..091130487791bd --- /dev/null +++ b/examples/state_containers_examples/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/package.json b/package.json index db5764e6e91bac..0ed74dd65d1ab9 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "packages": [ "packages/*", "x-pack", + "x-pack/plugins/*", "x-pack/legacy/plugins/*", "examples/*", "test/plugin_functional/plugins/*", @@ -114,7 +115,7 @@ "@babel/core": "^7.5.5", "@babel/register": "^7.7.0", "@elastic/apm-rum": "^4.6.0", - "@elastic/charts": "^16.0.2", + "@elastic/charts": "^16.1.0", "@elastic/datemath": "5.0.2", "@elastic/ems-client": "1.0.5", "@elastic/eui": "17.3.1", @@ -133,8 +134,10 @@ "@kbn/pm": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", + "@kbn/ui-shared-deps": "1.0.0", "@types/json-stable-stringify": "^1.0.32", "@types/lodash.clonedeep": "^4.5.4", + "@types/node-forge": "^0.9.0", "@types/react-grid-layout": "^0.16.7", "@types/recompose": "^0.30.5", "JSONStream": "1.3.5", @@ -168,7 +171,6 @@ "elastic-apm-node": "^3.2.0", "elasticsearch": "^16.5.0", "elasticsearch-browser": "^16.5.0", - "encode-uri-query": "1.0.1", "execa": "^3.2.0", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", @@ -217,6 +219,7 @@ "mustache": "2.3.2", "ngreact": "0.5.1", "node-fetch": "1.7.3", + "node-forge": "^0.9.1", "opn": "^5.5.0", "oppsy": "^2.0.0", "pegjs": "0.10.0", @@ -341,6 +344,7 @@ "@types/mustache": "^0.8.31", "@types/node": "^10.12.27", "@types/opn": "^5.1.0", + "@types/pegjs": "^0.10.1", "@types/pngjs": "^3.3.2", "@types/podium": "^1.0.0", "@types/prop-types": "^15.5.3", diff --git a/packages/kbn-dev-utils/certs/README.md b/packages/kbn-dev-utils/certs/README.md new file mode 100644 index 00000000000000..fdf78927894044 --- /dev/null +++ b/packages/kbn-dev-utils/certs/README.md @@ -0,0 +1,62 @@ +# Development certificates + +Kibana includes several development certificates to enable easy setup of TLS-encrypted communications with Elasticsearch. + +_Note: these certificates should **never** be used in production._ + +## Certificate information + +Certificates and keys are provided in multiple formats. These can be used by other packages to set up a new Elastic Stack with Kibana and Elasticsearch. The Certificate Authority (CA) private key is intentionally omitted from this package. + +### PEM + +* `ca.crt` -- A [PEM-formatted](https://tools.ietf.org/html/rfc1421) [X.509](https://tools.ietf.org/html/rfc5280) certificate that is used as a CA. +* `elasticsearch.crt` -- A PEM-formatted X.509 certificate and public key for Elasticsearch. +* `elasticsearch.key` -- A PEM-formatted [PKCS #1](https://tools.ietf.org/html/rfc8017) private key for Elasticsearch. +* `kibana.crt` -- A PEM-formatted X.509 certificate and public key for Kibana. +* `kibana.key` -- A PEM-formatted PKCS #1 private key for Kibana. + +### PKCS #12 + +* `elasticsearch.p12` -- A [PKCS #12](https://tools.ietf.org/html/rfc7292) encrypted key store / trust store that contains `ca.crt`, `elasticsearch.crt`, and a [PKCS #8](https://tools.ietf.org/html/rfc5208) encrypted version of `elasticsearch.key`. +* `kibana.p12` -- A PKCS #12 encrypted key store / trust store that contains `ca.crt`, `kibana.crt`, and a PKCS #8 encrypted version of `kibana.key`. + +The password used for both of these is "storepass". Other copies are also provided for testing purposes: + +* `elasticsearch_emptypassword.p12` -- The same PKCS #12 key store, encrypted with an empty password. +* `elasticsearch_nopassword.p12` -- The same PKCS #12 key store, not encrypted with a password. + +## Certificate generation + +[Elasticsearch cert-util](https://www.elastic.co/guide/en/elasticsearch/reference/current/certutil.html) and [OpenSSL](https://www.openssl.org/) were used to generate these certificates. The following commands were used from the root directory of Elasticsearch: + +``` +# Generate the PKCS #12 keystore for a CA, valid for 50 years +bin/elasticsearch-certutil ca -days 18250 --pass castorepass + +# Generate the PKCS #12 keystore for Elasticsearch and sign it with the CA +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name elasticsearch --dns localhost --pass storepass + +# Generate the PKCS #12 keystore for Kibana and sign it with the CA +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name kibana --dns localhost --pass storepass + +# Copy the PKCS #12 keystore for Elasticsearch with an empty password +openssl pkcs12 -in elasticsearch.p12 -nodes -passin pass:"storepass" -passout pass:"" | openssl pkcs12 -export -out elasticsearch_emptypassword.p12 -passout pass:"" + +# Manually create "elasticsearch_nopassword.p12" -- this can be done on macOS by importing the P12 key store into the Keychain and exporting it again + +# Extract the PEM-formatted X.509 certificate for the CA +openssl pkcs12 -in elasticsearch.p12 -out ca.crt -cacerts -passin pass:"storepass" -passout pass: + +# Extract the PEM-formatted PKCS #1 private key for Elasticsearch +openssl pkcs12 -in elasticsearch.p12 -nocerts -passin pass:"storepass" -passout pass:"keypass" | openssl rsa -passin pass:keypass -out elasticsearch.key + +# Extract the PEM-formatted X.509 certificate for Elasticsearch +openssl pkcs12 -in elasticsearch.p12 -out elasticsearch.crt -clcerts -passin pass:"storepass" -passout pass: + +# Extract the PEM-formatted PKCS #1 private key for Kibana +openssl pkcs12 -in kibana.p12 -nocerts -passin pass:"storepass" -passout pass:"keypass" | openssl rsa -passin pass:keypass -out kibana.key + +# Extract the PEM-formatted X.509 certificate for Kibana +openssl pkcs12 -in kibana.p12 -out kibana.crt -clcerts -passin pass:"storepass" -passout pass: +``` diff --git a/packages/kbn-dev-utils/certs/ca.crt b/packages/kbn-dev-utils/certs/ca.crt old mode 100755 new mode 100644 index 3e964823c5086b..217935b8d83f63 --- a/packages/kbn-dev-utils/certs/ca.crt +++ b/packages/kbn-dev-utils/certs/ca.crt @@ -1,20 +1,29 @@ +Bag Attributes + friendlyName: elasticsearch + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 31 39 38 30 33 37 +Key Attributes: +Bag Attributes + friendlyName: ca + 2.16.840.1.113894.746875.1.1: +subject=/CN=Elastic Certificate Tool Autogenerated CA +issuer=/CN=Elastic Certificate Tool Autogenerated CA -----BEGIN CERTIFICATE----- -MIIDSjCCAjKgAwIBAgIVAOgxLlE1RMGl2fYgTKDznvDL2vboMA0GCSqGSIb3DQEB -CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu -ZXJhdGVkIENBMB4XDTE5MDcxMTE3MzQ0OFoXDTIyMDcxMDE3MzQ0OFowNDEyMDAG -A1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5lcmF0ZWQgQ0Ew -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCNImKp/A9l++Ac7U5lvHOA -+fYRb8p7AgdfKBMB0v3bo+bpHjkbkf3vYHjo1xJSg5ls6EPK+Do4owkAgKJdrznI -5/efJOjgA+ylH4rgAfrRIQmiFEWZnAv86vJ+Iq83mfkPELb4dvXCi7AFQkzoM/rY -Lbi97xha5bA2SEmpYp7VhBTM9zWy+q9Tm5odPO8u2n75GpIM2RwipaXlL0ink+06 -/oweQJoivaDgpDOmUXCFPmpV3VCdhUGxDQPyG0upQkF+NbQoei4RmluPEmVz4S7I -TFLWjX7LeZVP63bJkcCgiq6Hm97kDtr9EYlPKhHm7UMWzhNzHbfvySMDzqAJC0KX -AgMBAAGjUzBRMB0GA1UdDgQWBBRKqaaQ/+jT+ipPLJe7qekp1N/zizAfBgNVHSME -GDAWgBRKqaaQ/+jT+ipPLJe7qekp1N/zizAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBCwUAA4IBAQA7Gcq8h8yDXvepfKUAcTTMCBZkI+g3qE1gfRwjW7587CIj -xnrzEqANU+Q1lv7IeQ158HiduDUMZfnvpuNwkf0HkqnRWb57RwfVdCAlAeZmzipq -5ZJWlIW4dbmk57nGLg4fCszedi0uSGytZ2/BUdpWyC0fAM97h7Agtr4xGGKMEL67 -uB55ijt61V62HZ5wWXWNO9m+wfmdnt+YQViQJHtpYz1oOmWhY3dpitZLfWs1sLLD -w3CZOhmWX7+P7+HlCkSBF4swzHOCI3THyX61NbLxju8VkTAjwbZPq4EOnVKnO6kr -RdwQVnzKnqG5fxfSGknNahy0pOhJHZlGLwECRlgF +MIIDSzCCAjOgAwIBAgIUW0brhEtYK3tUBYlXnUa+AMmAX6kwDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l +cmF0ZWQgQ0EwIBcNMTkxMjI3MTcwMjMyWhgPMjA2OTEyMTQxNzAyMzJaMDQxMjAw +BgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2VuZXJhdGVkIENB +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAplO5m5Xy8xERyA0/G5SM +Nu2QXkfS+m7ZTFjSmtwqX7BI1I6ISI4Yw8QxzcIgSbEGlSqb7baeT+A/1JQj0gZN +KOnKbazl+ujVRJpsfpt5iUsnQyVPheGekcHkB+9WkZPgZ1oGRENr/4Eb1VImQf+Y +yo/FUj8X939tYW0fficAqYKv8/4NWpBUbeop8wsBtkz738QKlmPkMwC4FbuF2/bN +vNuzQuRbGMVmPeyivZJRfDAMKExoXjCCLmbShdg4dUHsUjVeWQZ6s4vbims+8qF9 +b4bseayScQNNU3hc5mkfhEhSM0KB0lDpSvoCxuXvXzb6bOk7xIdYo+O4vHUhvSkQ +mwIDAQABo1MwUTAdBgNVHQ4EFgQUGu0mDnvDRnBdNBG8DxwPdWArB0kwHwYDVR0j +BBgwFoAUGu0mDnvDRnBdNBG8DxwPdWArB0kwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEASv/FYOwWGnQreH8ulcVupGeZj25dIjZiuKfJmslH8QN/ +pVCIzAxNZjGjCpKxbJoCu5U9USaBylbhigeBJEq4wmYTs/WPu4uYMgDj0MILuHin +RQqgEVG0uADGEgH2nnk8DeY8gQvGpJRQGlXNK8pb+pCsy6F8k/svGOeBND9osHfU +CVEo5nXjfq6JCFt6hPx7kl4h3/j3C4wNy/Dv/QINdpPsl6CnF17Q9R9d60WFv42/ +pkl7W1hszCG9foNJOJabuWfVoPkvKQjoCvPitZt/hCaFZAW49PmAVhK+DAohQ91l +TZhDmYqHoXNiRDQiUT68OS7RlfKgNpr/vMTZXDxpmw== -----END CERTIFICATE----- diff --git a/packages/kbn-dev-utils/certs/elasticsearch.crt b/packages/kbn-dev-utils/certs/elasticsearch.crt old mode 100755 new mode 100644 index b30e11e9bbce14..87ba02019903f1 --- a/packages/kbn-dev-utils/certs/elasticsearch.crt +++ b/packages/kbn-dev-utils/certs/elasticsearch.crt @@ -1,20 +1,29 @@ +Bag Attributes + friendlyName: elasticsearch + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 31 39 38 30 33 37 +Key Attributes: +Bag Attributes + friendlyName: elasticsearch + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 31 39 38 30 33 37 +subject=/CN=elasticsearch +issuer=/CN=Elastic Certificate Tool Autogenerated CA -----BEGIN CERTIFICATE----- -MIIDRDCCAiygAwIBAgIVAI8V1fwvXKykKtp5k0cLpTOtY+DVMA0GCSqGSIb3DQEB +MIIDQDCCAiigAwIBAgIVAI93OQE6tZffPyzenSg3ljE3JJBzMA0GCSqGSIb3DQEB CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu -ZXJhdGVkIENBMB4XDTE5MDcxMTE3MzUxOFoXDTIyMDcxMDE3MzUxOFowGDEWMBQG -A1UEAxMNZWxhc3RpY3NlYXJjaDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBALW+8gV6m6wYmTZmrXzNWKElE+ePkkikCviNfuWonWqxgAoWpAwAx2FvdhP3 -UDFbe38ydJX4oDgXeC25vdIR6z2uqzx+GXSNSybO7luuOUYQOP4Xf5Cj3zzXXMyu -nY1nZTVsChI9jAMz4cZZdUd04f4r4TBNxrFCcVR0uec5RGRXuP8rSQd9AbYFUVYf -jJeLb24asghb2Ku+c2JGvMqPEXFWFGOXFhUoIbRjCJNTDcr1ZXPof3+fO1l6HmhT -QBSqC4IZL8XqANltDT4tCQDD8L9+ckWJD8MP3wPkPUGZId2gLu++hrb9YfiP2upq -N/f3P7l5Fcisw1iwQC4+DGMTyfcCAwEAAaNpMGcwHQYDVR0OBBYEFGuiGk8HLpG2 -MyA24/J+GwxT32ikMB8GA1UdIwQYMBaAFEqpppD/6NP6Kk8sl7up6SnU3/OLMBoG -A1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAJBgNVHRMEAjAAMA0GCSqGSIb3DQEB -CwUAA4IBAQB8yfY0edAgq2KnJNWyl8NpHNfqtM27+/LR2V8OxVwxV1hc4ZilczLu -CXeqP9uqBVjcck6fvLrjy4LhSG0V05j51UMJ1FjFVTBuhlrDcd3j8848yWrmyz8z -vPYYY2vIN9d1NsBgufULwliBT4UJchsYE8xT5ayAzGHKCTlzHGHMTPzYjwac8nbT -nd2u+6h0OQOJn6K4v+RfXtN4EA8ZUrYxUkqHNS3cFB5sxH7JQGi25XJc5MfxyCwY -YOukxbN85ew861N6oVd+W+nGJu8WOLU88/uvCv+dLhnAlnnIOLqvmrD5m7gFsFO9 -Z7Xz/U1SbNipWy9OLOhqq2Ja59j8p9e5 +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDMxN1oYDzIwNjkxMjE0MTcwMzE3WjAYMRYw +FAYDVQQDEw1lbGFzdGljc2VhcmNoMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA2EkPfvE3ZNMjHCAQZhpImoXBCIN6KavvJSbVHRtLzAXB4wxige+vFQWb +4umqPeEeVH7FvrsRqn24tUgGIkag9p9AOwYxfcT3vwNqcK/EztIlYFs72pmYg7Ez +s6+qLc/YSLOT3aMoHKDHE93z1jYIDGccyjGbv9NsdgCbLHD0TQuqm+7pKy1MZoJm +0qn4KYw4kXakVNWlxm5GIwr8uqU/w4phrikcOOWqRzsxByoQajypLOA4eD/uWnI2 +zGyPQy7Bkxojiy1ss0CVlrl8fJgcjC4PONpm1ibUSX3SoZ8PopPThR6gvvwoQolR +rYu4+D+rsX7q/ldA6vBOiHBD8r4QoQIDAQABo2MwYTAdBgNVHQ4EFgQUSlIMCYYd +e72A0rUqaCkjVPkGPIwwHwYDVR0jBBgwFoAUGu0mDnvDRnBdNBG8DxwPdWArB0kw +FAYDVR0RBA0wC4IJbG9jYWxob3N0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQAD +ggEBAImbzBVAEjiLRsNDLP7QAl0k7lVmfQRFz5G95ZTAUSUgbqqymDvry47yInFF +3o12TuI1GxK5zHzi+qzpJLyrnGwGK5JBR+VGxIBBKFVcFh1WNGKV6kSO/zBzO7PO +4Jw4G7By/ImWvS0RBhBUQ9XbQZN3WcVkVVV8UQw5Y7JoKtM+fzyEKXKRCTsvgH+h +3+fUBgqwal2Mz4KPH57Jrtk209dtn7tnQxHTNLo0niHyEcfrpuG3YFqTwekr+5FF +FniIcYHPGjag1WzLIdyhe88FFpuav19mlCaxBACc7t97v+euSVUWnsKpy4dLydpv +NxJiI9eWbJZ7f5VM7o64pm7U1cU= -----END CERTIFICATE----- diff --git a/packages/kbn-dev-utils/certs/elasticsearch.key b/packages/kbn-dev-utils/certs/elasticsearch.key old mode 100755 new mode 100644 index 1013ce39712461..9ae4e314630d1e --- a/packages/kbn-dev-utils/certs/elasticsearch.key +++ b/packages/kbn-dev-utils/certs/elasticsearch.key @@ -1,27 +1,27 @@ -----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAtb7yBXqbrBiZNmatfM1YoSUT54+SSKQK+I1+5aidarGAChak -DADHYW92E/dQMVt7fzJ0lfigOBd4Lbm90hHrPa6rPH4ZdI1LJs7uW645RhA4/hd/ -kKPfPNdczK6djWdlNWwKEj2MAzPhxll1R3Th/ivhME3GsUJxVHS55zlEZFe4/ytJ -B30BtgVRVh+Ml4tvbhqyCFvYq75zYka8yo8RcVYUY5cWFSghtGMIk1MNyvVlc+h/ -f587WXoeaFNAFKoLghkvxeoA2W0NPi0JAMPwv35yRYkPww/fA+Q9QZkh3aAu776G -tv1h+I/a6mo39/c/uXkVyKzDWLBALj4MYxPJ9wIDAQABAoIBAQCb1ggrjn/gxo7I -yK3FL0XplqNEkCR8SLxndtvyC+w+Schh3hv3dst+zlXOtOZ8C9cOr7KrzS2EKwuP -GY6bi2XL0/NbwTwOZgCkXBahYfgWDV7w8DEfUoPd5UPa9XZ+gsOTVPolvcRKErhq -nNYk2SHWEMXb5zSRVUlbg2LL0pzD88bIuKJX+FwPvWcQc2P4OdVTq77iedcl82zZ -6PqTNqKMep7/odLQeBfX7OapOAviVnPYHe0TA114COOimR/pK8IA1OJymX5rgU7O -Wh+uNBSxdHsTTYTkAvw8Bt5Q8n1WCpQwZoYU3xWuSlu7eJ7kcgdFOu9r9GjSXysT -UYCd8s0BAoGBAPXPpCDRxjqF3/ToZ5x5dorKxxJyrmldzMJaUjqOv7y6kezbdBql -n7p3AJ5UfYUW/N6pgQXaWF4MPSyj7ItHhwHjL+v0Manmi5gq8oA30fplhjUlPre7 -Lx4v7SEmH739EHrkZ2ClIQwY3wKuN8mZKgw6RseFgphczDmhHCqEbjW3AoGBAL1H -fkl0RNdZ3nZg0u7MUVk8ytnqBsp7bNFhEs0zUl7ghu3NLaPt8qhirG638oMSCxqH -FPeM3/DryokQAym+UHYNMwiBziEUB2CKMMj7S5YFFWIldCxFeImCO2EP+y3hmbTZ -yjsznNrDzQtErZGP+JTRZcy9xF0oAfVt0G/O1Q3BAoGAa8bqINW5g6l1Q82uuEXt -evdkB6uu21YcVE8D5Nb4LMjk+KRUKObbvQc2hzVmf7dPklVh0+4jdsEJBYyuR3dK -M8KoHV3JdMQ4CrUx9JQFBjQDf0PgVvDEvQiogTNVEZlm42tIBHECp2o0RdmbblIw -xIG8zPi2BRYTGWWRkvbT18sCgYA+c/B/XBW62LRGavwuPsw4nY5xCH7lIIRvMZB6 -lIyBMaRToneEt2ZxmN08SwWBqdpwDlIkvB7H54UUZGwmwdzaltBX5jyVPX6RpAck -yYXPIi5EDAeg8+sptAbTp+pA4UdOHO5VSlpe9GwbY7XBabejotPsElFQS3sZ9/nm -amByAQKBgQCJWghllys1qk76/6PmeVjwjaK9n8o+94LWhqODXlACmDRyse5dwpYb -BIsMMZrNu1YsqDXlWpU7xNa6A8j4oa+EPnm/01PjdueAvMB/oE1woawM5tSsd8NQ -zeQPDhxjDxzaO5l4oJLZg6FT7iQAprhYZjgb8m1vz0D2Xid0A3Kgpw== +MIIEogIBAAKCAQEA2EkPfvE3ZNMjHCAQZhpImoXBCIN6KavvJSbVHRtLzAXB4wxi +ge+vFQWb4umqPeEeVH7FvrsRqn24tUgGIkag9p9AOwYxfcT3vwNqcK/EztIlYFs7 +2pmYg7Ezs6+qLc/YSLOT3aMoHKDHE93z1jYIDGccyjGbv9NsdgCbLHD0TQuqm+7p +Ky1MZoJm0qn4KYw4kXakVNWlxm5GIwr8uqU/w4phrikcOOWqRzsxByoQajypLOA4 +eD/uWnI2zGyPQy7Bkxojiy1ss0CVlrl8fJgcjC4PONpm1ibUSX3SoZ8PopPThR6g +vvwoQolRrYu4+D+rsX7q/ldA6vBOiHBD8r4QoQIDAQABAoIBAB+s44YV0aUEfvnZ +gE1TwBpRSGn0x2le8tEgFMoEe19P4Itd/vdEoQGVJrVevz38wDJjtpYuU3ICo5B5 +EdznNx+nRwLd71WaCSaCW45RT6Nyh2LLOcLUB9ARnZ7NNUEsVWKgWiF1iaRXr5Ar +S1Ct7RPT7hV2mnbHgfTuNcuWZ1D5BUcqNczNoHsV6guFChiwTr7ZObnKj4qJLwdu +ioYYWno4ZLgsk4SfW6DXUCvfKROfYdDd2rGu0NQ4QxT3Q98AsXlrlUITBQbpQEgy +5GSTEh/4sRYj4NQZqncDpPgXm22kYdU7voBjt/zu66oq1W6kKQ4JwPmyc2SI0haa +/pyCMtkCgYEA/y3vs59RvrM6xpT77lf7WigSBbIBQxeKs9RGNoN0Nn/eR0MlQAUG +SmCkkEOcUGuVMnoo5Kc73IP/Q1+O4UGg7f1Gs8KeFPFQMm/wcSL7obvRWray1Bw6 +ohITJPqZYZrw3hmkOMxkLpvUydivN1Unm7BezjOa+T/+OaV3PyAYufsCgYEA2Psb +S8OQhFiVbOKlMYOebvG+AnhAzJiSVus9R9NcViv20E61PRj2rfA398pYpZ8nxaQp +cWGy+POZbkxRCprZ1GHkwWjaQysgeOCbJv8nQ2oh5C0ZCaGw6lfmi2mN097+Prmx +QE8j8OKj3wVI6bniCF7vzwfG3c5cU73elLTAWRMCgYBoA/eDRlvx2ekJbU1MGDzy +wQann6l4Ca6WIt8D9Y13caPPdIVIlUO9KauqyoR7G39TdgwZODnkZ0Gz2s3I8BGD +MQyS1a/OZZcFGC/wTgw4HvD1gydd4qvbyHZZSnUfHiM0xUr1hAsKHKceJ980NNfS +VJAwiUSQeQ9NvC7hYlnx5QKBgDxESsmZcRuBa0eKEC4Xi7rvBEK1WfI58nOX9TZs ++3mnzm7/XZGxzFp1nWYC2uptsWNQ/H3UkBxbtOMQ6XWTmytFYX9i+zSq1uMcJ5wG +RMaRxQYWjJzDP1tnvM4+LDmL93w+oX/mO2pd2PxKAH2CtshybhNH6rGS7swHsboG +FmLnAoGAYTnTcWD1qiwjbJR5ZdukAjIq39cGcf0YOVJCiaFS+5vTirbw04ARvNyM +rxU8EpVN1sKC411pgNvlm6KZJHwihRRQoY+UI2fn78bHBH991QhlrTPO6TBZx7Aw ++hzyxqAiSBX65dQo0e4C15wZysQO/bdT5Def0+UTDR8j8ZgMAQg= -----END RSA PRIVATE KEY----- diff --git a/packages/kbn-dev-utils/certs/elasticsearch.p12 b/packages/kbn-dev-utils/certs/elasticsearch.p12 new file mode 100644 index 00000000000000..02a9183cd8a50e Binary files /dev/null and b/packages/kbn-dev-utils/certs/elasticsearch.p12 differ diff --git a/packages/kbn-dev-utils/certs/elasticsearch_emptypassword.p12 b/packages/kbn-dev-utils/certs/elasticsearch_emptypassword.p12 new file mode 100644 index 00000000000000..3162982ac635ad Binary files /dev/null and b/packages/kbn-dev-utils/certs/elasticsearch_emptypassword.p12 differ diff --git a/packages/kbn-dev-utils/certs/elasticsearch_nopassword.p12 b/packages/kbn-dev-utils/certs/elasticsearch_nopassword.p12 new file mode 100644 index 00000000000000..3a22a58d207dfb Binary files /dev/null and b/packages/kbn-dev-utils/certs/elasticsearch_nopassword.p12 differ diff --git a/packages/kbn-dev-utils/certs/kibana.crt b/packages/kbn-dev-utils/certs/kibana.crt new file mode 100644 index 00000000000000..1c83be587bff9b --- /dev/null +++ b/packages/kbn-dev-utils/certs/kibana.crt @@ -0,0 +1,29 @@ +Bag Attributes + friendlyName: kibana + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 32 32 33 30 33 39 +Key Attributes: +Bag Attributes + friendlyName: kibana + localKeyID: 54 69 6D 65 20 31 35 37 37 34 36 36 32 32 33 30 33 39 +subject=/CN=kibana +issuer=/CN=Elastic Certificate Tool Autogenerated CA +-----BEGIN CERTIFICATE----- +MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== +-----END CERTIFICATE----- diff --git a/packages/kbn-dev-utils/certs/kibana.key b/packages/kbn-dev-utils/certs/kibana.key new file mode 100644 index 00000000000000..4a4e6b4cb8c360 --- /dev/null +++ b/packages/kbn-dev-utils/certs/kibana.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAkMGGG0LW0QSieLjWXNviIEVPuzTS9tWXm0JRCDICu4wKjjK7 +Ftm+QwE8+NRXfhkBVBVGGI8KPj0I+rCR7ITiaP4DW99h68nbzCO2D73r5LM5LhBg +n18UNw8NkUwAi4A9EDgpMB7590zH9JaJaFf3jxlmmQ2GWl4t2uQ3VovxxRaCcrIB +dCVdn+l8ioSZBmhHybPaVl9uvcx/10LxjceARIpiaWAfIeCZw2DxSIkHao/rJdm/ +pEtYR/ZC+YAQDF4xVL3DZ1/j5sk48iaxqO+ZNJLvrKQX9WzMLkLd7kpjPk0CF5Lx +Q5ktAzlREq6QORAtUwVBkc+qPyPPF8eDLne+9QIDAQABAoIBAHl9suxWYKz00te3 +alJtSZAEHDLm1tjL034/XnseXiTCGGnYMiWvgnwCIgZFUVlH61GCuV4LT3GFEHA2 +mYKE1PGBn5gQF8MpnAvtPPRhVgaQVUFQBYg86F59h8mWnC545sciG4+DsA/apUem +wJSOn/u+Odni/AwEV0ALolZFBhl+0rccSr+6paJnzJ7QNiIn6EWbgb0n9WXqkhap +TqoPclBHm0ObeBI6lNyfvBZ8HB3hyjWZInNCaAs9DnkNPh4evuttUn/KlOPOVn9r +xz2UYsmVW6E+yPXUpSYkFQN9aaPF6alOz8PIfF8Wit7pmZMmInluGcwi/us9+ZTN +8gNvpoECgYEA0KC7XEoXRsBTN4kPznkGftvj1dtgB35W/HxXNouArQQjCbLhqcsA +jqaK0f+stYzSWZXGsKl9yQU9KA7u/wCHmLep70l7WsYYUKdkhWouK0HU5MeeLwB0 +N4ekQOQuQGqelqMo7IG2hQhTYD9PB4F3G0Sz1FgdObfuGPKfvNFVjckCgYEAsaAA +IY/TpRBWeWZfyXrnkp3atOPzkdpjb6cfT8Kib9bIECXr7ULUxA5QANX05ofodhsW +3+7iW5wicyZ1VNVEsPRL0aw7YUbNpBvob8faBUZ2KEdKQr42IfVOo7TQnvVXtumR +UE+dNvWUL2PbL0wMxD1XbMSmOze/wF8X2CeyDc0CgYBQnLqol2xVBz1gaRJ1emgb +HoXzfVemrZeY6cadKdwnfkC3n6n4fJsTg6CCMiOe5vHkca4bVvJmeSK/Vr3cRG0g +gl8kOaVzVrXQfE2oC3YZes9zMvqZOLivODcsZ77DXy82D4dhk2FeF/B3cR7tTIYk +QDCoLP/l7H8QnrdAMza2mQKBgDODwuX475ncviehUEB/26+DBo4V2ms/mj0kjAk2 +2qNy+DzuspjyHADsYbmMU+WUHxA51Q2HG7ET/E3HJpo+7BgiEecye1pADZ391hCt +Nob3I4eU/W2T+uEoYvFJnIOthg3veYyAOolY+ewwmr4B4WX8oGFUOx3Lklo5ehHf +mV01AoGBAI/c6OoHdcqQsZxKlxDNLyB2bTbowAcccoZIOjkC5fkkbsmMDLfScBfW +Q4YYJsmJBdrWNvo7jCl17Mcc4Is3RlmHDrItRkaZj+ehqAN3ejrnPLdgYeW/5XDK +e7yBj7oJd4oKZc59jVytdHvo5R8K0QohAv9gQEZ/tdypX+xWe+5E +-----END RSA PRIVATE KEY----- diff --git a/packages/kbn-dev-utils/certs/kibana.p12 b/packages/kbn-dev-utils/certs/kibana.p12 new file mode 100644 index 00000000000000..06bbd23881290b Binary files /dev/null and b/packages/kbn-dev-utils/certs/kibana.p12 differ diff --git a/packages/kbn-dev-utils/src/certs.ts b/packages/kbn-dev-utils/src/certs.ts index 0d340e4e8c906f..f72e3ee547b5cb 100644 --- a/packages/kbn-dev-utils/src/certs.ts +++ b/packages/kbn-dev-utils/src/certs.ts @@ -22,3 +22,14 @@ import { resolve } from 'path'; export const 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'); +export const ES_P12_PASSWORD = 'storepass'; +export const ES_EMPTYPASSWORD_P12_PATH = resolve( + __dirname, + '../certs/elasticsearch_emptypassword.p12' +); +export const ES_NOPASSWORD_P12_PATH = resolve(__dirname, '../certs/elasticsearch_nopassword.p12'); +export const KBN_KEY_PATH = resolve(__dirname, '../certs/kibana.key'); +export const KBN_CERT_PATH = resolve(__dirname, '../certs/kibana.crt'); +export const KBN_P12_PATH = resolve(__dirname, '../certs/kibana.p12'); +export const KBN_P12_PASSWORD = 'storepass'; diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index dc12613cf2a9e9..2fc29b71b262ec 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -25,7 +25,19 @@ export { ToolingLogCollectingWriter, } from './tooling_log'; export { createAbsolutePathSerializer } from './serializers'; -export { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } from './certs'; +export { + CA_CERT_PATH, + ES_KEY_PATH, + ES_CERT_PATH, + ES_P12_PATH, + ES_P12_PASSWORD, + ES_EMPTYPASSWORD_P12_PATH, + ES_NOPASSWORD_P12_PATH, + KBN_KEY_PATH, + KBN_CERT_PATH, + KBN_P12_PATH, + KBN_P12_PASSWORD, +} from './certs'; export { run, createFailError, createFlagError, combineErrors, isFailError, Flags } from './run'; export { REPO_ROOT } from './repo_root'; export { KbnClient } from './kbn_client'; diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 665f80e3802e35..ceb4a5b6aece14 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -35,7 +35,7 @@ const { createCliError } = require('./errors'); const { promisify } = require('util'); const treeKillAsync = promisify(require('tree-kill')); const { parseSettings, SettingsFilter } = require('./settings'); -const { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } = require('@kbn/dev-utils'); +const { CA_CERT_PATH, ES_P12_PATH, ES_P12_PASSWORD } = require('@kbn/dev-utils'); const readFile = util.promisify(fs.readFile); // listen to data on stream until map returns anything but undefined @@ -261,9 +261,9 @@ exports.Cluster = class Cluster { const esArgs = [].concat(options.esArgs || []); if (this._ssl) { esArgs.push('xpack.security.http.ssl.enabled=true'); - esArgs.push(`xpack.security.http.ssl.key=${ES_KEY_PATH}`); - esArgs.push(`xpack.security.http.ssl.certificate=${ES_CERT_PATH}`); - esArgs.push(`xpack.security.http.ssl.certificate_authorities=${CA_CERT_PATH}`); + esArgs.push(`xpack.security.http.ssl.keystore.path=${ES_P12_PATH}`); + esArgs.push(`xpack.security.http.ssl.keystore.type=PKCS12`); + esArgs.push(`xpack.security.http.ssl.keystore.password=${ES_P12_PASSWORD}`); } const args = parseSettings(extractConfigFiles(esArgs, installPath, { log: this._log }), { diff --git a/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js b/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js index d3181a748ffbbe..d374abe5db0683 100644 --- a/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js +++ b/packages/kbn-es/src/integration_tests/__fixtures__/es_bin.js @@ -34,6 +34,8 @@ if (!start) { let serverUrl; const server = createServer( { + // Note: the integration uses the ES_P12_PATH, but that keystore contains + // the same key/cert as ES_KEY_PATH and ES_CERT_PATH key: ssl ? fs.readFileSync(ES_KEY_PATH) : undefined, cert: ssl ? fs.readFileSync(ES_CERT_PATH) : undefined, }, diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index dd570e27e32826..dfbc04477bd409 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -17,7 +17,7 @@ * under the License. */ -const { ToolingLog, CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } = require('@kbn/dev-utils'); +const { ToolingLog, ES_P12_PATH, ES_P12_PASSWORD } = require('@kbn/dev-utils'); const execa = require('execa'); const { Cluster } = require('../cluster'); const { installSource, installSnapshot, installArchive } = require('../install'); @@ -252,9 +252,9 @@ describe('#start(installPath)', () => { const config = extractConfigFiles.mock.calls[0][0]; expect(config).toContain('xpack.security.http.ssl.enabled=true'); - expect(config).toContain(`xpack.security.http.ssl.key=${ES_KEY_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate=${ES_CERT_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate_authorities=${CA_CERT_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.path=${ES_P12_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.type=PKCS12`); + expect(config).toContain(`xpack.security.http.ssl.keystore.password=${ES_P12_PASSWORD}`); }); it(`doesn't setup SSL when disabled`, async () => { @@ -319,9 +319,9 @@ describe('#run()', () => { const config = extractConfigFiles.mock.calls[0][0]; expect(config).toContain('xpack.security.http.ssl.enabled=true'); - expect(config).toContain(`xpack.security.http.ssl.key=${ES_KEY_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate=${ES_CERT_PATH}`); - expect(config).toContain(`xpack.security.http.ssl.certificate_authorities=${CA_CERT_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.path=${ES_P12_PATH}`); + expect(config).toContain(`xpack.security.http.ssl.keystore.type=PKCS12`); + expect(config).toContain(`xpack.security.http.ssl.keystore.password=${ES_P12_PASSWORD}`); }); it(`doesn't setup SSL when disabled`, async () => { diff --git a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js index c51168ae2d91c3..e02c38494991a9 100755 --- a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js +++ b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js @@ -30,8 +30,6 @@ exports.getWebpackConfig = function(kibanaPath, projectRoot, config) { ui: fromKibana('src/legacy/ui/public'), test_harness: fromKibana('src/test_harness/public'), querystring: 'querystring-browser', - moment$: fromKibana('webpackShims/moment'), - 'moment-timezone$': fromKibana('webpackShims/moment-timezone'), // Dev defaults for test bundle https://github.com/elastic/kibana/blob/6998f074542e8c7b32955db159d15661aca253d7/src/core_plugins/tests_bundle/index.js#L73-L78 ng_mock$: fromKibana('src/test_utils/public/ng_mock'), diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c3f3f2f477fdd0..a3debf78fb8c86 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -4500,6 +4500,14 @@ var certs_1 = __webpack_require__(422); exports.CA_CERT_PATH = certs_1.CA_CERT_PATH; exports.ES_KEY_PATH = certs_1.ES_KEY_PATH; exports.ES_CERT_PATH = certs_1.ES_CERT_PATH; +exports.ES_P12_PATH = certs_1.ES_P12_PATH; +exports.ES_P12_PASSWORD = certs_1.ES_P12_PASSWORD; +exports.ES_EMPTYPASSWORD_P12_PATH = certs_1.ES_EMPTYPASSWORD_P12_PATH; +exports.ES_NOPASSWORD_P12_PATH = certs_1.ES_NOPASSWORD_P12_PATH; +exports.KBN_KEY_PATH = certs_1.KBN_KEY_PATH; +exports.KBN_CERT_PATH = certs_1.KBN_CERT_PATH; +exports.KBN_P12_PATH = certs_1.KBN_P12_PATH; +exports.KBN_P12_PASSWORD = certs_1.KBN_P12_PASSWORD; var run_1 = __webpack_require__(423); exports.run = run_1.run; exports.createFailError = run_1.createFailError; @@ -36986,6 +36994,14 @@ const path_1 = __webpack_require__(16); exports.CA_CERT_PATH = path_1.resolve(__dirname, '../certs/ca.crt'); exports.ES_KEY_PATH = path_1.resolve(__dirname, '../certs/elasticsearch.key'); exports.ES_CERT_PATH = path_1.resolve(__dirname, '../certs/elasticsearch.crt'); +exports.ES_P12_PATH = path_1.resolve(__dirname, '../certs/elasticsearch.p12'); +exports.ES_P12_PASSWORD = 'storepass'; +exports.ES_EMPTYPASSWORD_P12_PATH = path_1.resolve(__dirname, '../certs/elasticsearch_emptypassword.p12'); +exports.ES_NOPASSWORD_P12_PATH = path_1.resolve(__dirname, '../certs/elasticsearch_nopassword.p12'); +exports.KBN_KEY_PATH = path_1.resolve(__dirname, '../certs/kibana.key'); +exports.KBN_CERT_PATH = path_1.resolve(__dirname, '../certs/kibana.crt'); +exports.KBN_P12_PATH = path_1.resolve(__dirname, '../certs/kibana.p12'); +exports.KBN_P12_PASSWORD = 'storepass'; /***/ }), @@ -58252,6 +58268,7 @@ function getProjectPaths({ if (!ossOnly) { projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/legacy/plugins/*')); } diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index 6d5e67dca7d139..6ba8d58a26f88f 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -46,6 +46,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option if (!ossOnly) { projectPaths.push(resolve(rootPath, 'x-pack')); + projectPaths.push(resolve(rootPath, 'x-pack/plugins/*')); projectPaths.push(resolve(rootPath, 'x-pack/legacy/plugins/*')); } diff --git a/packages/kbn-ui-shared-deps/README.md b/packages/kbn-ui-shared-deps/README.md new file mode 100644 index 00000000000000..3d3ee37ca5a755 --- /dev/null +++ b/packages/kbn-ui-shared-deps/README.md @@ -0,0 +1,3 @@ +# `@kbn/ui-shared-deps` + +Shared dependencies that must only have a single instance are installed and re-exported from here. To consume them, import the package and merge the `externals` export into your webpack config so that all references to the supported modules will be remapped to use the global versions. \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js new file mode 100644 index 00000000000000..250abd162f91d7 --- /dev/null +++ b/packages/kbn-ui-shared-deps/entry.js @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// must load before angular +export const Jquery = require('jquery'); +window.$ = window.jQuery = Jquery; + +export const Angular = require('angular'); +export const ElasticCharts = require('@elastic/charts'); +export const ElasticEui = require('@elastic/eui'); +export const ElasticEuiLibServices = require('@elastic/eui/lib/services'); +export const ElasticEuiLightTheme = require('@elastic/eui/dist/eui_theme_light.json'); +export const ElasticEuiDarkTheme = require('@elastic/eui/dist/eui_theme_dark.json'); +export const Moment = require('moment'); +export const MomentTimezone = require('moment-timezone/moment-timezone'); +export const React = require('react'); +export const ReactDom = require('react-dom'); +export const ReactIntl = require('react-intl'); +export const ReactRouter = require('react-router'); // eslint-disable-line +export const ReactRouterDom = require('react-router-dom'); + +// load timezone data into moment-timezone +Moment.tz.load(require('moment-timezone/data/packed/latest.json')); diff --git a/packages/kbn-ui-shared-deps/index.d.ts b/packages/kbn-ui-shared-deps/index.d.ts new file mode 100644 index 00000000000000..132445bbde7451 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.d.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Absolute path to the distributable directory + */ +export const distDir: string; + +/** + * Filename of the main bundle file in the distributable directory + */ +export const distFilename: string; + +/** + * Filename of the dark-theme css file in the distributable directory + */ +export const darkCssDistFilename: string; + +/** + * Filename of the light-theme css file in the distributable directory + */ +export const lightCssDistFilename: string; + +/** + * Externals mapping inteded to be used in a webpack config + */ +export const externals: { + [key: string]: string; +}; diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js new file mode 100644 index 00000000000000..cef25295b35d74 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.js @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const Path = require('path'); + +exports.distDir = Path.resolve(__dirname, 'target'); +exports.distFilename = 'kbn-ui-shared-deps.js'; +exports.lightCssDistFilename = 'kbn-ui-shared-deps.light.css'; +exports.darkCssDistFilename = 'kbn-ui-shared-deps.dark.css'; +exports.externals = { + angular: '__kbnSharedDeps__.Angular', + '@elastic/charts': '__kbnSharedDeps__.ElasticCharts', + '@elastic/eui': '__kbnSharedDeps__.ElasticEui', + '@elastic/eui/lib/services': '__kbnSharedDeps__.ElasticEuiLibServices', + '@elastic/eui/dist/eui_theme_light.json': '__kbnSharedDeps__.ElasticEuiLightTheme', + '@elastic/eui/dist/eui_theme_dark.json': '__kbnSharedDeps__.ElasticEuiDarkTheme', + jquery: '__kbnSharedDeps__.Jquery', + moment: '__kbnSharedDeps__.Moment', + 'moment-timezone': '__kbnSharedDeps__.MomentTimezone', + react: '__kbnSharedDeps__.React', + 'react-dom': '__kbnSharedDeps__.ReactDom', + 'react-intl': '__kbnSharedDeps__.ReactIntl', + 'react-router': '__kbnSharedDeps__.ReactRouter', + 'react-router-dom': '__kbnSharedDeps__.ReactRouterDom', +}; diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json new file mode 100644 index 00000000000000..014467d204d96c --- /dev/null +++ b/packages/kbn-ui-shared-deps/package.json @@ -0,0 +1,29 @@ +{ + "name": "@kbn/ui-shared-deps", + "version": "1.0.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build": "node scripts/build", + "kbn:bootstrap": "node scripts/build --dev", + "kbn:watch": "node scripts/build --watch" + }, + "devDependencies": { + "@elastic/eui": "17.3.1", + "@elastic/charts": "^16.1.0", + "@kbn/dev-utils": "1.0.0", + "@yarnpkg/lockfile": "^1.1.0", + "angular": "^1.7.9", + "css-loader": "^2.1.1", + "del": "^5.1.0", + "jquery": "^3.4.1", + "mini-css-extract-plugin": "0.8.0", + "moment": "^2.24.0", + "moment-timezone": "^0.5.27", + "react-dom": "^16.12.0", + "react-intl": "^2.8.0", + "react": "^16.12.0", + "read-pkg": "^5.2.0", + "webpack": "4.41.0" + } +} \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/scripts/build.js b/packages/kbn-ui-shared-deps/scripts/build.js new file mode 100644 index 00000000000000..8b7c22dac24ff3 --- /dev/null +++ b/packages/kbn-ui-shared-deps/scripts/build.js @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const Path = require('path'); + +const { run, createFailError } = require('@kbn/dev-utils'); +const webpack = require('webpack'); +const Stats = require('webpack/lib/Stats'); +const del = require('del'); + +const { getWebpackConfig } = require('../webpack.config'); + +run( + async ({ log, flags }) => { + log.info('cleaning previous build output'); + await del(Path.resolve(__dirname, '../target')); + + const compiler = webpack( + getWebpackConfig({ + dev: flags.dev, + }) + ); + + /** @param {webpack.Stats} stats */ + const onCompilationComplete = stats => { + const took = Math.round((stats.endTime - stats.startTime) / 1000); + + if (!stats.hasErrors() && !stats.hasWarnings()) { + log.success(`webpack completed in about ${took} seconds`); + return; + } + + throw createFailError( + `webpack failure in about ${took} seconds\n${stats.toString({ + colors: true, + ...Stats.presetToOptions('minimal'), + })}` + ); + }; + + if (flags.watch) { + compiler.hooks.done.tap('report on stats', stats => { + try { + onCompilationComplete(stats); + } catch (error) { + log.error(error.message); + } + }); + + compiler.hooks.watchRun.tap('report on start', () => { + process.stdout.cursorTo(0, 0); + process.stdout.clearScreenDown(); + log.info('Running webpack compilation...'); + }); + + compiler.watch({}, error => { + if (error) { + log.error('Fatal webpack error'); + log.error(error); + process.exit(1); + } + }); + + return; + } + + onCompilationComplete( + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else { + resolve(stats); + } + }); + }) + ); + }, + { + description: 'build @kbn/ui-shared-deps', + flags: { + boolean: ['watch', 'dev'], + help: ` + --watch Run in watch mode + --dev Build development friendly version + `, + }, + } +); diff --git a/packages/kbn-ui-shared-deps/tsconfig.json b/packages/kbn-ui-shared-deps/tsconfig.json new file mode 100644 index 00000000000000..c5c3cba147fcfb --- /dev/null +++ b/packages/kbn-ui-shared-deps/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "index.d.ts" + ] +} diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js new file mode 100644 index 00000000000000..87cca2cc897f84 --- /dev/null +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const Path = require('path'); + +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { REPO_ROOT } = require('@kbn/dev-utils'); +const webpack = require('webpack'); + +const SharedDeps = require('./index'); + +const MOMENT_SRC = require.resolve('moment/min/moment-with-locales.js'); + +exports.getWebpackConfig = ({ dev = false } = {}) => ({ + mode: dev ? 'development' : 'production', + entry: { + [SharedDeps.distFilename.replace(/\.js$/, '')]: './entry.js', + [SharedDeps.darkCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_dark.css', + '@elastic/charts/dist/theme_only_dark.css', + ], + [SharedDeps.lightCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_light.css', + '@elastic/charts/dist/theme_only_light.css', + ], + }, + context: __dirname, + devtool: dev ? '#cheap-source-map' : false, + output: { + path: SharedDeps.distDir, + filename: '[name].js', + sourceMapFilename: '[file].map', + publicPath: '__REPLACE_WITH_PUBLIC_PATH__', + devtoolModuleFilenameTemplate: info => + `kbn-ui-shared-deps/${Path.relative(REPO_ROOT, info.absoluteResourcePath)}`, + library: '__kbnSharedDeps__', + }, + + module: { + noParse: [MOMENT_SRC], + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + + resolve: { + alias: { + moment: MOMENT_SRC, + }, + }, + + optimization: { + noEmitOnErrors: true, + }, + + performance: { + // NOTE: we are disabling this as those hints + // are more tailored for the final bundles result + // and not for the webpack compilations performance itself + hints: false, + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: '[name].css', + }), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': dev ? '"development"' : '"production"', + }), + ], +}); diff --git a/renovate.json5 b/renovate.json5 index f069e961c0f2be..560403046b0a51 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -625,6 +625,14 @@ '@types/node-fetch', ], }, + { + groupSlug: 'node-forge', + groupName: 'node-forge related packages', + packageNames: [ + 'node-forge', + '@types/node-forge', + ], + }, { groupSlug: 'nodemailer', groupName: 'nodemailer related packages', @@ -673,6 +681,14 @@ '@types/parse-link-header', ], }, + { + groupSlug: 'pegjs', + groupName: 'pegjs related packages', + packageNames: [ + 'pegjs', + '@types/pegjs', + ], + }, { groupSlug: 'pngjs', groupName: 'pngjs related packages', diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 6b13d0dc32d3f0..9cf5691b88399e 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -102,6 +102,8 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { } ensureNotDefined('server.ssl.certificate'); ensureNotDefined('server.ssl.key'); + ensureNotDefined('server.ssl.keystore.path'); + ensureNotDefined('server.ssl.truststore.path'); ensureNotDefined('elasticsearch.ssl.certificateAuthorities'); const elasticsearchHosts = ( @@ -119,6 +121,8 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { }); set('server.ssl.enabled', true); + // TODO: change this cert/key to KBN_CERT_PATH and KBN_KEY_PATH from '@kbn/dev-utils'; will require some work to avoid breaking + // functional tests. Once that is done, the existing test cert/key at DEV_SSL_CERT_PATH and DEV_SSL_KEY_PATH can be deleted. set('server.ssl.certificate', DEV_SSL_CERT_PATH); set('server.ssl.key', DEV_SSL_KEY_PATH); set('elasticsearch.hosts', elasticsearchHosts); diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index b70ac610f24a79..173d73ffab664c 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -55,6 +55,7 @@ - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - [On the server side](#on-the-server-side) - [On the client side](#on-the-client-side) + - [Updates an application navlink at runtime](#updates-an-app-navlink-at-runtime) Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. @@ -1624,3 +1625,31 @@ class MyPlugin { It's not currently possible to use a similar pattern on the client-side. Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. + +### Updates an application navlink at runtime + +The application API now provides a way to updates some of a registered application's properties after registration. + +```typescript +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'Application disabled', + }) + } +``` \ No newline at end of file diff --git a/src/core/README.md b/src/core/README.md index 8863658e0040c3..f4539191d448bd 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -7,6 +7,7 @@ Core Plugin API Documentation: - [Core Public API](/docs/development/core/public/kibana-plugin-public.md) - [Core Server API](/docs/development/core/server/kibana-plugin-server.md) - [Conventions for Plugins](./CONVENTIONS.md) + - [Testing Kibana Plugins](./TESTING.md) - [Migration guide for porting existing plugins](./MIGRATION.md) Internal Documentation: diff --git a/src/core/TESTING.md b/src/core/TESTING.md new file mode 100644 index 00000000000000..6139820d02a14a --- /dev/null +++ b/src/core/TESTING.md @@ -0,0 +1,254 @@ +# Testing Kibana Plugins + +This document outlines best practices and patterns for testing Kibana Plugins. + +- [Strategy](#strategy) +- [Core Integrations](#core-integrations) + - [Core Mocks](#core-mocks) + - [Strategies for specific Core APIs](#strategies-for-specific-core-apis) + - [HTTP Routes](#http-routes) + - [SavedObjects](#savedobjects) + - [Elasticsearch](#elasticsearch) +- [Plugin Integrations](#plugin-integrations) +- [Plugin Contracts](#plugin-contracts) + +## Strategy + +In general, we recommend three tiers of tests: +- Unit tests: small, fast, exhaustive, make heavy use of mocks for external dependencies +- Integration tests: higher-level tests that verify interactions between systems (eg. HTTP APIs, Elasticsearch API calls, calling other plugin contracts). +- End-to-end tests (e2e): tests that verify user-facing behavior through the browser + +These tiers should roughly follow the traditional ["testing pyramid"](https://martinfowler.com/articles/practical-test-pyramid.html), where there are more exhaustive testing at the unit level, fewer at the integration level, and very few at the functional level. + +## New concerns in the Kibana Platform + +The Kibana Platform introduces new concepts that legacy plugins did not have concern themselves with. Namely: +- **Lifecycles**: plugins now have explicit lifecycle methods that must interop with Core APIs and other plugins. +- **Shared runtime**: plugins now all run in the same process at the same time. On the frontend, this is different behavior than the legacy plugins. Developers should take care not to break other plugins when interacting with their enviornment (Node.js or Browser). +- **Single page application**: Kibana's frontend is now a single-page application where all plugins are running, but only one application is mounted at a time. Plugins need to handle mounting and unmounting, cleanup, and avoid overriding global browser behaviors in this shared space. +- **Dependency management**: plugins must now explicitly declare their dependencies on other plugins, both required and optional. Plugins should ensure to test conditions where a optional dependency is missing. + +Simply porting over existing tests when migrating your plugin to the Kibana Platform will leave blind spots in test coverage. It is highly recommended that plugins add new tests that cover these new concerns. + +## Core Integrations + +### Core Mocks + +When testing a plugin's integration points with Core APIs, it is heavily recommended to utilize the mocks provided in `src/core/server/mocks` and `src/core/public/mocks`. The majority of these mocks are dumb `jest` mocks that mimic the interface of their respective Core APIs, however they do not return realistic return values. + +If the unit under test expects a particular response from a Core API, the test will need to set this return value explicitly. The return values are type checked to match the Core API where possible to ensure that mocks are updated when Core APIs changed. + +#### Example + +```ts +import { elasticsearchServiceMock } from 'src/core/server/mocks'; + +test('my test', async () => { + // Setup mock and faked response + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.callAsCurrentUser.mockResolvedValue(/** insert ES response here */); + + // Call unit under test with mocked client + const result = await myFunction(esClient); + + // Assert that client was called with expected arguments + expect(esClient.callAsCurrentUser).toHaveBeenCalledWith(/** expected args */); + // Expect that unit under test returns expected value based on client's response + expect(result).toEqual(/** expected return value */) +}); +``` + +### Strategies for specific Core APIs + +#### HTTP Routes + +_How to test route handlers_ + +### Applications + +Kibana Platform applications have less control over the page than legacy applications did. It is important that your app is built to handle it's co-habitance with other plugins in the browser. Applications are mounted and unmounted from the DOM as the user navigates between them, without full-page refreshes, as a single-page application (SPA). + +These long-lived sessions make cleanup more important than before. It's entirely possible a user has a single browsing session open for weeks at a time, without ever doing a full-page refresh. Common things that need to be cleaned up (and tested!) when your application is unmounted: +- Subscriptions and polling (eg. `uiSettings.get$()`) +- Any Core API calls that set state (eg. `core.chrome.setIsVisible`). +- Open connections (eg. a Websocket) + +While applications do get an opportunity to unmount and run cleanup logic, it is also important that you do not _depend_ on this logic to run. The browser tab may get closed without running cleanup logic, so it is not guaranteed to be run. For instance, you should not depend on unmounting logic to run in order to save state to `localStorage` or to the backend. + +#### Example + +By following the [renderApp](./CONVENTIONS.md#applications) convention, you can greatly reduce the amount of logic in your application's mount function. This makes testing your application's actual rendering logic easier. + +```tsx +/** public/plugin.ts */ +class Plugin { + setup(core) { + core.application.register({ + // id, title, etc. + async mount(params) { + const [{ renderApp }, [coreStart, startDeps]] = await Promise.all([ + import('./application'), + core.getStartServices() + ]); + + return renderApp(params, coreStart, startDeps); + } + }) + } +} +``` + +We _could_ still write tests for this logic, but you may find that you're just asserting the same things that would be covered by type-checks. + +
+See example + +```ts +/** public/plugin.test.ts */ +jest.mock('./application', () => ({ renderApp: jest.fn() })); +import { coreMock } from 'src/core/public/mocks'; +import { renderApp: renderAppMock } from './application'; +import { Plugin } from './plugin'; + +describe('Plugin', () => { + it('registers an app', () => { + const coreSetup = coreMock.createSetup(); + new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); + expect(coreSetup.application.register).toHaveBeenCalledWith({ + id: 'myApp', + mount: expect.any(Function) + }); + }); + + // Test the glue code from Plugin -> renderApp + it('application.mount wires up dependencies to renderApp', async () => { + const coreSetup = coreMock.createSetup(); + const [coreStartMock, startDepsMock] = await coreSetup.getStartServices(); + const unmountMock = jest.fn(); + renderAppMock.mockReturnValue(unmountMock); + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + + new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); + // Grab registered mount function + const mount = coreSetup.application.register.mock.calls[0][0].mount; + + const unmount = await mount(params); + expect(renderAppMock).toHaveBeenCalledWith(params, coreStartMock, startDepsMock); + expect(unmount).toBe(unmountMock); + }); +}); +``` + +
+ +The more interesting logic is in `renderApp`: + +```ts +/** public/application.ts */ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { AppMountParams, CoreStart } from 'src/core/public'; +import { AppRoot } from './components/app_root'; + +export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { + // Hide the chrome while this app is mounted for a full screen experience + core.chrome.setIsVisible(false); + + // uiSettings subscription + const uiSettingsClient = core.uiSettings.client; + const pollingSubscription = uiSettingClient.get$('mysetting1').subscribe(async mySetting1 => { + const value = core.http.fetch(/** use `mySetting1` in request **/); + // ... + }); + + // Render app + ReactDOM.render( + , + element + ); + + return () => { + // Unmount UI + ReactDOM.unmountComponentAtNode(element); + // Close any subscriptions + pollingSubscription.unsubscribe(); + // Make chrome visible again + core.chrome.setIsVisible(true); + }; +}; +``` + +In testing `renderApp` you should be verifying that: +1) Your application mounts and unmounts correctly +2) Cleanup logic is completed as expected + +```ts +/** public/application.test.ts */ +import { coreMock } from 'src/core/public/mocks'; +import { renderApp } from './application'; + +describe('renderApp', () => { + it('mounts and unmounts UI', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + + // Verify some expected DOM element is rendered into the element + const unmount = renderApp(params, core, {}); + expect(params.element.querySelector('.some-app-class')).not.toBeUndefined(); + // Verify the element is empty after unmounting + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('unsubscribes from uiSettings', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + // Create a fake Subject you can use to monitor observers + const settings$ = new Subject(); + core.uiSettings.get$.mockReturnValue(settings$); + + // Verify mounting adds an observer + const unmount = renderApp(params, core, {}); + expect(settings$.observers.length).toBe(1); + // Verify no observers remaining after unmount is called + unmount(); + expect(settings$.observers.length).toBe(0); + }); + + it('resets chrome visibility', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + + // Verify stateful Core API was called on mount + const unmount = renderApp(params, core, {}); + expect(core.chrome.setIsVisible).toHaveBeenCalledWith(false); + core.chrome.setIsVisible.mockClear(); // reset mock + // Verify stateful Core API was called on unmount + unmount(); + expect(core.chrome.setIsVisible).toHaveBeenCalledWith(true); + }) +}); +``` + +#### SavedObjects + +_How to test SO operations_ + +#### Elasticsearch + +_How to test ES clients_ + +## Plugin Integrations + +_How to test against specific plugin APIs (eg. data plugin)_ + +## Plugin Contracts + +_How to test your plugin's exposed API_ + +Guidelines: +- Plugins should never interact with other plugins' REST API directly +- Plugins should interact with other plugins via JavaScript contracts +- Exposed contracts need to be well tested to ensure breaking changes are detected easily diff --git a/src/core/public/application/application_leave.test.ts b/src/core/public/application/application_leave.test.ts new file mode 100644 index 00000000000000..e06183d8bb8d96 --- /dev/null +++ b/src/core/public/application/application_leave.test.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isConfirmAction, getLeaveAction } from './application_leave'; +import { AppLeaveActionType } from './types'; + +describe('isConfirmAction', () => { + it('returns true if action is confirm', () => { + expect(isConfirmAction({ type: AppLeaveActionType.confirm, text: 'message' })).toEqual(true); + }); + it('returns false if action is default', () => { + expect(isConfirmAction({ type: AppLeaveActionType.default })).toEqual(false); + }); +}); + +describe('getLeaveAction', () => { + it('returns the default action provided by the handler', () => { + expect(getLeaveAction(actions => actions.default())).toEqual({ + type: AppLeaveActionType.default, + }); + }); + it('returns the confirm action provided by the handler', () => { + expect(getLeaveAction(actions => actions.confirm('some message'))).toEqual({ + type: AppLeaveActionType.confirm, + text: 'some message', + }); + expect(getLeaveAction(actions => actions.confirm('another message', 'a title'))).toEqual({ + type: AppLeaveActionType.confirm, + text: 'another message', + title: 'a title', + }); + }); +}); diff --git a/src/core/public/application/application_leave.tsx b/src/core/public/application/application_leave.tsx new file mode 100644 index 00000000000000..7b69d70d3f6f6e --- /dev/null +++ b/src/core/public/application/application_leave.tsx @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + AppLeaveActionFactory, + AppLeaveActionType, + AppLeaveAction, + AppLeaveConfirmAction, + AppLeaveHandler, +} from './types'; + +const appLeaveActionFactory: AppLeaveActionFactory = { + confirm(text: string, title?: string) { + return { type: AppLeaveActionType.confirm, text, title }; + }, + default() { + return { type: AppLeaveActionType.default }; + }, +}; + +export function isConfirmAction(action: AppLeaveAction): action is AppLeaveConfirmAction { + return action.type === AppLeaveActionType.confirm; +} + +export function getLeaveAction(handler?: AppLeaveHandler): AppLeaveAction { + if (!handler) { + return appLeaveActionFactory.default(); + } + return handler(appLeaveActionFactory); +} diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b2e2161c92cc8e..dee47315fc3222 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { @@ -25,17 +25,21 @@ import { InternalApplicationStart, ApplicationStart, InternalApplicationSetup, + App, + LegacyApp, } from './types'; import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); const createInternalSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), registerLegacyApp: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); @@ -50,8 +54,7 @@ const createInternalStartContractMock = (): jest.Mocked(); return { - availableApps: new Map(), - availableLegacyApps: new Map(), + applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), getComponent: jest.fn(), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index d064b17ace1423..4672a42c9eb060 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -18,24 +18,42 @@ */ import { createElement } from 'react'; -import { Subject } from 'rxjs'; -import { bufferCount, skip, takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { bufferCount, skip, take, takeUntil } from 'rxjs/operators'; import { shallow } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; +import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; - -function mount() {} +import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; + +const createApp = (props: Partial): App => { + return { + id: 'some-id', + title: 'some-title', + mount: () => () => undefined, + ...props, + }; +}; + +const createLegacyApp = (props: Partial): LegacyApp => { + return { + id: 'some-id', + title: 'some-title', + appUrl: '/my-url', + ...props, + }; +}; + +let setupDeps: MockLifecycle<'setup'>; +let startDeps: MockLifecycle<'start'>; +let service: ApplicationService; describe('#setup()', () => { - let setupDeps: MockLifecycle<'setup'>; - let startDeps: MockLifecycle<'start'>; - let service: ApplicationService; - beforeEach(() => { const http = httpServiceMock.createSetupContract({ basePath: '/test' }); setupDeps = { @@ -44,7 +62,7 @@ describe('#setup()', () => { injectedMetadata: injectedMetadataServiceMock.createSetupContract(), }; setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); - startDeps = { http, injectedMetadata: setupDeps.injectedMetadata }; + startDeps = { http, overlays: overlayServiceMock.createStartContract() }; service = new ApplicationService(); }); @@ -52,9 +70,9 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the id \\"app1\\""` ); @@ -65,37 +83,91 @@ describe('#setup()', () => { await service.start(startDeps); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); + it('allows to register a statusUpdater for the application', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject(app => ({})); + setup.register(pluginId, createApp({ id: 'app1', updater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + const { applications$ } = await service.start(startDeps); + + let applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + + updater$.next(app => ({ + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + })); + + applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + }); + it('throws an error if an App with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1' }))).toThrow(); - register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any); + register(Symbol(), createApp({ id: 'app-next', appRoute: '/app/app3' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app3' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app3\\""` ); - expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app3' }))).not.toThrow(); }); it('throws an error if an App starts with the HTTP base path', () => { const { register } = service.setup(setupDeps); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/test/app2' })) ).toThrowErrorMatchingInlineSnapshot( `"Cannot register an application route that includes HTTP base path"` ); @@ -106,9 +178,11 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app2' } as any); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` + registerLegacyApp(createLegacyApp({ id: 'app2' })); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app2\\""` ); }); @@ -116,22 +190,228 @@ describe('#setup()', () => { const { registerLegacyApp } = service.setup(setupDeps); await service.start(startDeps); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"Applications cannot be registered after \\"setup\\""` - ); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); it('throws an error if a LegacyApp with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app1' } as any); + registerLegacyApp(createLegacyApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1:other' }))).not.toThrow(); + }); + }); + + describe('registerAppStatusUpdater', () => { + it('updates status fields', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.register(pluginId, createApp({ id: 'app2' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'App inaccessible due to reason', + }; + } + return { + tooltip: 'App accessible', + }; + }) + ); + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + tooltip: 'App accessible', + }) + ); + }); + + it(`properly combine with application's updater$`, async () => { + const setup = service.setup(setupDeps); + const pluginId = Symbol('plugin'); + const appStatusUpdater$ = new BehaviorSubject(app => ({ + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + })); + setup.register(pluginId, createApp({ id: 'app1', updater$: appStatusUpdater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.accessible, + tooltip: 'App inaccessible due to reason', + }; + } + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }) + ); + + const { applications$ } = await service.start(startDeps); + const applications = await applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('applies the most restrictive status in case of multiple updaters', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }) + ); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + }) + ); + }); + + it('emits on applications$ when a status updater changes', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + + const statusUpdater = new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }); + setup.registerAppUpdater(statusUpdater); + + const start = await service.start(startDeps); + let latestValue: ReadonlyMap = new Map(); + start.applications$.subscribe(apps => { + latestValue = apps; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }) + ); + + statusUpdater.next(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('also updates legacy apps', async () => { + const setup = service.setup(setupDeps); + + setup.registerLegacyApp(createLegacyApp({ id: 'app1' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: true, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }) + ); }); }); @@ -140,18 +420,16 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - registerMountContext(pluginId, 'test' as any, mount as any); + const mount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, mount); expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); }); }); describe('#start()', () => { - let setupDeps: MockLifecycle<'setup'>; - let startDeps: MockLifecycle<'start'>; - let service: ApplicationService; - beforeEach(() => { MockHistory.push.mockReset(); + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); setupDeps = { http, @@ -159,7 +437,7 @@ describe('#start()', () => { injectedMetadata: injectedMetadataServiceMock.createSetupContract(), }; setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); - startDeps = { http, injectedMetadata: setupDeps.injectedMetadata }; + startDeps = { http, overlays: overlayServiceMock.createStartContract() }; service = new ApplicationService(); }); @@ -173,35 +451,40 @@ describe('#start()', () => { setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'app2' } as any); - - const { availableApps, availableLegacyApps } = await service.start(startDeps); - - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'app2' })); + + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); + + expect(availableApps.size).toEqual(2); + expect([...availableApps.keys()]).toEqual(['app1', 'app2']); + expect(availableApps.get('app1')).toEqual( + expect.objectContaining({ + appRoute: '/app/app1', + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(availableApps.get('app2')).toEqual( + expect.objectContaining({ + appUrl: '/my-url', + id: 'app2', + legacy: true, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); }); it('passes appIds to capabilities', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - register(Symbol(), { id: 'app2', mount } as any); - register(Symbol(), { id: 'app3', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); + register(Symbol(), createApp({ id: 'app2' })); + register(Symbol(), createApp({ id: 'app3' })); await service.start(startDeps); expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ @@ -224,29 +507,15 @@ describe('#start()', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount } as any); - registerLegacyApp({ id: 'legacyApp2' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp2' })); - const { availableApps, availableLegacyApps } = await service.start(startDeps); + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "legacyApp1" => Object { - "id": "legacyApp1", - }, - } - `); + expect([...availableApps.keys()]).toEqual(['app1', 'legacyApp1']); }); describe('getComponent', () => { @@ -264,6 +533,7 @@ describe('#start()', () => { } } mounters={Map {}} + setAppLeaveHandler={[Function]} /> `); }); @@ -291,9 +561,9 @@ describe('#start()', () => { it('creates URL for registered appId', async () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { getUrlForApp } = await service.start(startDeps); @@ -320,41 +590,41 @@ describe('#start()', () => { const { navigateToApp } = await service.start(startDeps); - navigateToApp('myTestApp'); + await navigateToApp('myTestApp'); expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); - navigateToApp('myOtherApp'); + await navigateToApp('myOtherApp'); expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); }); it('changes the browser history for custom appRoutes', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); - navigateToApp('myTestApp'); + await navigateToApp('myTestApp'); expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); - navigateToApp('app2'); + await navigateToApp('app2'); expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', undefined); }); it('appends a path if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); - navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); + await navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); expect(MockHistory.push).toHaveBeenCalledWith( '/app/myTestApp/deep/link/to/location/2', undefined ); - navigateToApp('app2', { path: 'deep/link/to/location/2' }); + await navigateToApp('app2', { path: 'deep/link/to/location/2' }); expect(MockHistory.push).toHaveBeenCalledWith( '/custom/path/deep/link/to/location/2', undefined @@ -364,14 +634,14 @@ describe('#start()', () => { it('includes state if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); - navigateToApp('myTestApp', { state: 'my-state' }); + await navigateToApp('myTestApp', { state: 'my-state' }); expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); - navigateToApp('app2', { state: 'my-state' }); + await navigateToApp('app2', { state: 'my-state' }); expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', 'my-state'); }); @@ -382,7 +652,7 @@ describe('#start()', () => { const { navigateToApp } = await service.start(startDeps); - navigateToApp('myTestApp'); + await navigateToApp('myTestApp'); expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/myTestApp'); }); @@ -430,7 +700,7 @@ describe('#start()', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + registerLegacyApp(createLegacyApp({ id: 'baseApp:legacyApp1' })); const { navigateToApp } = await service.start(startDeps); @@ -439,3 +709,39 @@ describe('#start()', () => { }); }); }); + +describe('#stop()', () => { + let addListenerSpy: jest.SpyInstance; + let removeListenerSpy: jest.SpyInstance; + + beforeEach(() => { + addListenerSpy = jest.spyOn(window, 'addEventListener'); + removeListenerSpy = jest.spyOn(window, 'removeEventListener'); + + MockHistory.push.mockReset(); + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps = { + http, + context: contextServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + service = new ApplicationService(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('removes the beforeunload listener', async () => { + service.setup(setupDeps); + await service.start(startDeps); + expect(addListenerSpy).toHaveBeenCalledTimes(1); + expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + const handler = addListenerSpy.mock.calls[0][1]; + service.stop(); + expect(removeListenerSpy).toHaveBeenCalledTimes(1); + expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', handler); + }); +}); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index a96b9dea9b9c71..c69b96274aa95e 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -18,31 +18,40 @@ */ import React from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; -import { InjectedMetadataSetup, InjectedMetadataStart } from '../injected_metadata'; +import { InjectedMetadataSetup } from '../injected_metadata'; import { HttpSetup, HttpStart } from '../http'; +import { OverlayStart } from '../overlays'; import { ContextSetup, IContextContainer } from '../context'; import { AppRouter } from './ui'; -import { CapabilitiesService, Capabilities } from './capabilities'; +import { Capabilities, CapabilitiesService } from './capabilities'; import { App, - LegacyApp, + AppBase, + AppLeaveHandler, AppMount, AppMountDeprecated, AppMounter, - LegacyAppMounter, - Mounter, + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, + AppUpdater, InternalApplicationSetup, InternalApplicationStart, + LegacyApp, + LegacyAppMounter, + Mounter, } from './types'; +import { getLeaveAction, isConfirmAction } from './application_leave'; interface SetupDeps { context: ContextSetup; http: HttpSetup; injectedMetadata: InjectedMetadataSetup; + history?: History; /** * Only necessary for redirecting to legacy apps * @deprecated @@ -51,19 +60,20 @@ interface SetupDeps { } interface StartDeps { - injectedMetadata: InjectedMetadataStart; http: HttpStart; + overlays: OverlayStart; } // Mount functions with two arguments are assumed to expect deprecated `context` object. const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => mount.length === 2; -const filterAvailable = (map: Map, capabilities: Capabilities) => - new Map( - [...map].filter( +function filterAvailable(m: Map, capabilities: Capabilities) { + return new Map( + [...m].filter( ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true ) ); +} const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); const getAppUrl = (mounters: Map, appId: string, path: string = '') => @@ -71,16 +81,25 @@ const getAppUrl = (mounters: Map, appId: string, path: string = .replace(/\/{2,}/g, '/') // Remove duplicate slashes .replace(/\/$/, ''); // Remove trailing slash +const allApplicationsFilter = '__ALL__'; + +interface AppUpdaterWrapper { + application: string; + updater: AppUpdater; +} + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps = new Map(); - private readonly legacyApps = new Map(); + private readonly apps = new Map(); private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); + private readonly appLeaveHandlers = new Map(); private currentAppId$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); + private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); private registrationClosed = false; private history?: History; @@ -92,19 +111,34 @@ export class ApplicationService { http: { basePath }, injectedMetadata, redirectTo = (path: string) => (window.location.href = path), + history, }: SetupDeps): InternalApplicationSetup { const basename = basePath.get(); // Only setup history if we're not in legacy mode if (!injectedMetadata.getLegacyMode()) { - this.history = createBrowserHistory({ basename }); + this.history = history || createBrowserHistory({ basename }); } // If we do not have history available, use redirectTo to do a full page refresh. this.navigate = (url, state) => // basePath not needed here because `history` is configured with basename this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); + this.mountContext = context.createContextContainer(); + const registerStatusUpdater = (application: string, updater$: Observable) => { + const updaterId = Symbol(); + const subscription = updater$.subscribe(updater => { + const nextValue = new Map(this.statusUpdaters$.getValue()); + nextValue.set(updaterId, { + application, + updater, + }); + this.statusUpdaters$.next(nextValue); + }); + this.subscriptions.push(subscription); + }; + return { registerMountContext: this.mountContext!.registerContext, register: (plugin, app) => { @@ -139,7 +173,17 @@ export class ApplicationService { this.currentAppId$.next(app.id); return unmount; }; - this.apps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: false, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), @@ -152,15 +196,25 @@ export class ApplicationService { if (this.registrationClosed) { throw new Error('Applications cannot be registered after "setup"'); - } else if (this.legacyApps.has(app.id)) { - throw new Error(`A legacy application is already registered with the id "${app.id}"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); } else if (basename && appRoute!.startsWith(basename)) { throw new Error('Cannot register an application route that includes HTTP base path'); } const appBasePath = basePath.prepend(appRoute); const mount: LegacyAppMounter = () => redirectTo(appBasePath); - this.legacyApps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: true, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute, appBasePath, @@ -168,40 +222,138 @@ export class ApplicationService { unmountBeforeMounting: true, }); }, + registerAppUpdater: (appUpdater$: Observable) => + registerStatusUpdater(allApplicationsFilter, appUpdater$), }; } - public async start({ injectedMetadata, http }: StartDeps): Promise { + public async start({ http, overlays }: StartDeps): Promise { if (!this.mountContext) { throw new Error('ApplicationService#setup() must be invoked before start.'); } this.registrationClosed = true; + window.addEventListener('beforeunload', this.onBeforeUnload); + const { capabilities } = await this.capabilities.start({ appIds: [...this.mounters.keys()], http, }); const availableMounters = filterAvailable(this.mounters, capabilities); + const availableApps = filterAvailable(this.apps, capabilities); + + const applications$ = new BehaviorSubject(availableApps); + this.statusUpdaters$ + .pipe( + map(statusUpdaters => { + return new Map( + [...availableApps].map(([id, app]) => [ + id, + updateStatus(app, [...statusUpdaters.values()]), + ]) + ); + }) + ) + .subscribe(apps => applications$.next(apps)); return { - availableApps: filterAvailable(this.apps, capabilities), - availableLegacyApps: filterAvailable(this.legacyApps, capabilities), + applications$, capabilities, currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)), registerMountContext: this.mountContext.registerContext, getUrlForApp: (appId, { path }: { path?: string } = {}) => getAppUrl(availableMounters, appId, path), - navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => { - this.navigate!(getAppUrl(availableMounters, appId, path), state); - this.currentAppId$.next(appId); + navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => { + const app = applications$.value.get(appId); + if (app && app.status !== AppStatus.accessible) { + // should probably redirect to the error page instead + throw new Error(`Trying to navigate to an inaccessible application: ${appId}`); + } + if (await this.shouldNavigate(overlays)) { + this.appLeaveHandlers.delete(this.currentAppId$.value!); + this.navigate!(getAppUrl(availableMounters, appId, path), state); + this.currentAppId$.next(appId); + } + }, + getComponent: () => { + if (!this.history) { + return null; + } + return ( + + ); }, - getComponent: () => - this.history ? : null, }; } + private setAppLeaveHandler = (appId: string, handler: AppLeaveHandler) => { + this.appLeaveHandlers.set(appId, handler); + }; + + private async shouldNavigate(overlays: OverlayStart): Promise { + const currentAppId = this.currentAppId$.value; + if (currentAppId === undefined) { + return true; + } + const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); + if (isConfirmAction(action)) { + const confirmed = await overlays.openConfirm(action.text, { + title: action.title, + 'data-test-subj': 'appLeaveConfirmModal', + }); + if (!confirmed) { + return false; + } + } + return true; + } + + private onBeforeUnload = (event: Event) => { + const currentAppId = this.currentAppId$.value; + if (currentAppId === undefined) { + return; + } + const action = getLeaveAction(this.appLeaveHandlers.get(currentAppId)); + if (isConfirmAction(action)) { + event.preventDefault(); + // some browsers accept a string return value being the message displayed + event.returnValue = action.text as any; + } + }; + public stop() { this.stop$.next(); this.currentAppId$.complete(); + this.statusUpdaters$.complete(); + this.subscriptions.forEach(sub => sub.unsubscribe()); + window.removeEventListener('beforeunload', this.onBeforeUnload); } } + +const updateStatus = (app: T, statusUpdaters: AppUpdaterWrapper[]): T => { + let changes: Partial = {}; + statusUpdaters.forEach(wrapper => { + if (wrapper.application !== allApplicationsFilter && wrapper.application !== app.id) { + return; + } + const fields = wrapper.updater(app); + if (fields) { + changes = { + ...changes, + ...fields, + // status and navLinkStatus enums are ordered by reversed priority + // if multiple updaters wants to change these fields, we will always follow the priority order. + status: Math.max(changes.status ?? 0, fields.status ?? 0), + navLinkStatus: Math.max(changes.navLinkStatus ?? 0, fields.navLinkStatus ?? 0), + }; + } + }); + return { + ...app, + ...changes, + }; +}; diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 9c4427c772a5ea..e7ea330657648a 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -27,8 +27,17 @@ export { AppUnmount, AppMountContext, AppMountParameters, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, ApplicationSetup, ApplicationStart, + AppLeaveHandler, + AppLeaveActionType, + AppLeaveAction, + AppLeaveDefaultAction, + AppLeaveConfirmAction, // Internal types InternalApplicationStart, LegacyApp, diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx new file mode 100644 index 00000000000000..edf3583f384b83 --- /dev/null +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -0,0 +1,167 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createRenderer } from './utils'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import { ApplicationService } from '../application_service'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { contextServiceMock } from '../../context/context_service.mock'; +import { injectedMetadataServiceMock } from '../../injected_metadata/injected_metadata_service.mock'; +import { MockLifecycle } from '../test_types'; +import { overlayServiceMock } from '../../overlays/overlay_service.mock'; +import { AppMountParameters } from '../types'; + +describe('ApplicationService', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + let history: MemoryHistory; + let update: ReturnType; + + const navigate = (path: string) => { + history.push(path); + return update(); + }; + + beforeEach(() => { + history = createMemoryHistory(); + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + + http.post.mockResolvedValue({ navLinks: {} }); + + setupDeps = { + http, + context: contextServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + history: history as any, + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { http, overlays: overlayServiceMock.createStartContract() }; + service = new ApplicationService(); + }); + + describe('leaving an application that registered an app leave handler', () => { + it('navigates to the new app if action is default', async () => { + startDeps.overlays.openConfirm.mockResolvedValue(true); + + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave(actions => actions.default()); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await navigate('/app/app1'); + await navigateToApp('app2'); + + expect(startDeps.overlays.openConfirm).not.toHaveBeenCalled(); + expect(history.entries.length).toEqual(3); + expect(history.entries[2].pathname).toEqual('/app/app2'); + }); + + it('navigates to the new app if action is confirm and user accepted', async () => { + startDeps.overlays.openConfirm.mockResolvedValue(true); + + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave(actions => actions.confirm('confirmation-message', 'confirmation-title')); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await navigate('/app/app1'); + await navigateToApp('app2'); + + expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1); + expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith( + 'confirmation-message', + expect.objectContaining({ title: 'confirmation-title' }) + ); + expect(history.entries.length).toEqual(3); + expect(history.entries[2].pathname).toEqual('/app/app2'); + }); + + it('blocks navigation to the new app if action is confirm and user declined', async () => { + startDeps.overlays.openConfirm.mockResolvedValue(false); + + const { register } = service.setup(setupDeps); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave(actions => actions.confirm('confirmation-message', 'confirmation-title')); + return () => undefined; + }, + }); + register(Symbol(), { + id: 'app2', + title: 'App2', + mount: ({}: AppMountParameters) => { + return () => undefined; + }, + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await navigate('/app/app1'); + await navigateToApp('app2'); + + expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1); + expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith( + 'confirmation-message', + expect.objectContaining({ title: 'confirmation-title' }) + ); + expect(history.entries.length).toEqual(2); + expect(history.entries[1].pathname).toEqual('/app/app1'); + }); + }); +}); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 10544c348afb03..cc71cf8722df4d 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -36,6 +36,7 @@ describe('AppContainer', () => { const mockMountersToMounters = () => new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); + const setAppLeaveHandlerMock = () => undefined; beforeEach(() => { mounters = new Map([ @@ -46,7 +47,13 @@ describe('AppContainer', () => { createAppMounter('app3', '
App 3
', '/custom/path'), ] as Array>); history = createMemoryHistory(); - update = createRenderer(); + update = createRenderer( + + ); }); it('calls mount handler and returned unmount function when navigating between apps', async () => { @@ -78,7 +85,13 @@ describe('AppContainer', () => { mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); history = createMemoryHistory(); - update = createRenderer(); + update = createRenderer( + + ); await navigate('/fake-login'); @@ -90,7 +103,13 @@ describe('AppContainer', () => { mounters.set(...createAppMounter('login', '
Login Page
', '/fake-login')); mounters.set(...createAppMounter('spaces', '
Custom Space
', '/spaces/fake-login')); history = createMemoryHistory(); - update = createRenderer(); + update = createRenderer( + + ); await navigate('/spaces/fake-login'); @@ -124,7 +143,13 @@ describe('AppContainer', () => { it('should not remount when when changing pages within app using hash history', async () => { history = createHashHistory(); - update = createRenderer(); + update = createRenderer( + + ); const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); @@ -153,6 +178,7 @@ describe('AppContainer', () => { Object { "appBasePath": "/app/legacyApp1", "element":
, + "onAppLeave": [Function], }, ] `); @@ -165,6 +191,7 @@ describe('AppContainer', () => { Object { "appBasePath": "/app/baseApp", "element":
, + "onAppLeave": [Function], }, ] `); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index c026851af7eb80..0d955482d2226c 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -34,6 +34,9 @@ import { SavedObjectsStart } from '../saved_objects'; /** @public */ export interface AppBase { + /** + * The unique identifier of the application + */ id: string; /** @@ -41,15 +44,62 @@ export interface AppBase { */ title: string; + /** + * The initial status of the application. + * Defaulting to `accessible` + */ + status?: AppStatus; + + /** + * The initial status of the application's navLink. + * Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` + * See {@link AppNavLinkStatus} + */ + navLinkStatus?: AppNavLinkStatus; + + /** + * An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime. + * + * @example + * + * How to update an application navLink at runtime + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * private appUpdater = new BehaviorSubject(() => ({})); + * + * setup({ application }) { + * application.register({ + * id: 'my-app', + * title: 'My App', + * updater$: this.appUpdater, + * async mount(params) { + * const { renderApp } = await import('./application'); + * return renderApp(params); + * }, + * }); + * } + * + * start() { + * // later, when the navlink needs to be updated + * appUpdater.next(() => { + * navLinkStatus: AppNavLinkStatus.disabled, + * }) + * } + * ``` + */ + updater$?: Observable; + /** * An ordinal used to sort nav links relative to one another for display. */ order?: number; /** - * An observable for a tooltip shown when hovering over app link. + * A tooltip shown when hovering over app link. */ - tooltip$?: Observable; + tooltip?: string; /** * A EUI iconType that will be used for the app's icon. This icon @@ -67,8 +117,76 @@ export interface AppBase { * Custom capabilities defined by the app. */ capabilities?: Partial; + + /** + * Flag to keep track of legacy applications. + * For internal use only. any value will be overridden when registering an App. + * + * @internal + */ + legacy?: boolean; + + /** + * Hide the UI chrome when the application is mounted. Defaults to `false`. + * Takes precedence over chrome service visibility settings. + */ + chromeless?: boolean; } +/** + * Accessibility status of an application. + * + * @public + */ +export enum AppStatus { + /** + * Application is accessible. + */ + accessible = 0, + /** + * Application is not accessible. + */ + inaccessible = 1, +} + +/** + * Status of the application's navLink. + * + * @public + */ +export enum AppNavLinkStatus { + /** + * The application navLink will be `visible` if the application's {@link AppStatus} is set to `accessible` + * and `hidden` if the application status is set to `inaccessible`. + */ + default = 0, + /** + * The application navLink is visible and clickable in the navigation bar. + */ + visible = 1, + /** + * The application navLink is visible but inactive and not clickable in the navigation bar. + */ + disabled = 2, + /** + * The application navLink does not appear in the navigation bar. + */ + hidden = 3, +} + +/** + * Defines the list of fields that can be updated via an {@link AppUpdater}. + * @public + */ +export type AppUpdatableFields = Pick; + +/** + * Updater for applications. + * see {@link ApplicationSetup} + * @public + */ +export type AppUpdater = (app: AppBase) => Partial | undefined; + /** * Extension of {@link AppBase | common app properties} with the mount function. * @public @@ -230,6 +348,117 @@ export interface AppMountParameters { * ``` */ appBasePath: string; + + /** + * A function that can be used to register a handler that will be called + * when the user is leaving the current application, allowing to + * prompt a confirmation message before actually changing the page. + * + * This will be called either when the user goes to another application, or when + * trying to close the tab or manually changing the url. + * + * @example + * + * ```ts + * // application.tsx + * import React from 'react'; + * import ReactDOM from 'react-dom'; + * import { BrowserRouter, Route } from 'react-router-dom'; + * + * import { CoreStart, AppMountParams } from 'src/core/public'; + * import { MyPluginDepsStart } from './plugin'; + * + * export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { + * const { renderApp, hasUnsavedChanges } = await import('./application'); + * onAppLeave(actions => { + * if(hasUnsavedChanges()) { + * return actions.confirm('Some changes were not saved. Are you sure you want to leave?'); + * } + * return actions.default(); + * }); + * return renderApp(params); + * } + * ``` + */ + onAppLeave: (handler: AppLeaveHandler) => void; +} + +/** + * A handler that will be executed before leaving the application, either when + * going to another application or when closing the browser tab or manually changing + * the url. + * Should return `confirm` to to prompt a message to the user before leaving the page, or `default` + * to keep the default behavior (doing nothing). + * + * See {@link AppMountParameters} for detailed usage examples. + * + * @public + */ +export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; + +/** + * Possible type of actions on application leave. + * + * @public + */ +export enum AppLeaveActionType { + confirm = 'confirm', + default = 'default', +} + +/** + * Action to return from a {@link AppLeaveHandler} to execute the default + * behaviour when leaving the application. + * + * See {@link AppLeaveActionFactory} + * + * @public + */ +export interface AppLeaveDefaultAction { + type: AppLeaveActionType.default; +} + +/** + * Action to return from a {@link AppLeaveHandler} to show a confirmation + * message when trying to leave an application. + * + * See {@link AppLeaveActionFactory} + * + * @public + */ +export interface AppLeaveConfirmAction { + type: AppLeaveActionType.confirm; + text: string; + title?: string; +} + +/** + * Possible actions to return from a {@link AppLeaveHandler} + * + * See {@link AppLeaveConfirmAction} and {@link AppLeaveDefaultAction} + * + * @public + * */ +export type AppLeaveAction = AppLeaveDefaultAction | AppLeaveConfirmAction; + +/** + * Factory provided when invoking a {@link AppLeaveHandler} to retrieve the {@link AppLeaveAction} to execute. + */ +export interface AppLeaveActionFactory { + /** + * Returns a confirm action, resulting on prompting a message to the user before leaving the + * application, allowing him to choose if he wants to stay on the app or confirm that he + * wants to leave. + * + * @param text The text to display in the confirmation message + * @param title (optional) title to display in the confirmation message + */ + confirm(text: string, title?: string): AppLeaveConfirmAction; + /** + * Returns a default action, resulting on executing the default behavior when + * the user tries to leave an application + */ + default(): AppLeaveDefaultAction; } /** @@ -263,6 +492,35 @@ export interface ApplicationSetup { */ register(app: App): void; + /** + * Register an application updater that can be used to change the {@link AppUpdatableFields} fields + * of all applications at runtime. + * + * This is meant to be used by plugins that needs to updates the whole list of applications. + * To only updates a specific application, use the `updater$` property of the registered application instead. + * + * @example + * + * How to register an application updater that disables some applications: + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * setup({ application }) { + * application.registerAppUpdater( + * new BehaviorSubject(app => { + * if (myPluginApi.shouldDisable(app)) + * return { + * status: AppStatus.inaccessible, + * }; + * }) + * ); + * } + * } + * ``` + */ + registerAppUpdater(appUpdater$: Observable): void; + /** * Register a context provider for application mounting. Will only be available to applications that depend on the * plugin that registered this context. Deprecated, use {@link CoreSetup.getStartServices}. @@ -278,7 +536,7 @@ export interface ApplicationSetup { } /** @internal */ -export interface InternalApplicationSetup { +export interface InternalApplicationSetup extends Pick { /** * Register an mountable application to the system. * @param plugin - opaque ID of the plugin that registers this application @@ -317,13 +575,13 @@ export interface ApplicationStart { capabilities: RecursiveReadonly; /** - * Navigiate to a given app + * Navigate to a given app * * @param appId * @param options.path - optional path inside application to deep link to * @param options.state - optional state to forward to the application */ - navigateToApp(appId: string, options?: { path?: string; state?: any }): void; + navigateToApp(appId: string, options?: { path?: string; state?: any }): Promise; /** * Returns a relative URL to a given app, including the global base path. @@ -351,16 +609,11 @@ export interface ApplicationStart { export interface InternalApplicationStart extends Pick { /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - */ - availableApps: ReadonlyMap; - /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - * @internal + * Apps available based on the current capabilities. + * Should be used to show navigation links and make routing decisions. + * Applications manually disabled from the client-side using {@link AppUpdater} */ - availableLegacyApps: ReadonlyMap; + applications$: Observable>; /** * Register a context provider for application mounting. Will only be available to applications that depend on the diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 153582e805fa1b..8afd4d0ca05514 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -26,15 +26,20 @@ import React, { MutableRefObject, } from 'react'; -import { AppUnmount, Mounter } from '../types'; +import { AppUnmount, Mounter, AppLeaveHandler } from '../types'; import { AppNotFound } from './app_not_found_screen'; interface Props { appId: string; mounter?: Mounter; + setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; } -export const AppContainer: FunctionComponent = ({ mounter, appId }: Props) => { +export const AppContainer: FunctionComponent = ({ + mounter, + appId, + setAppLeaveHandler, +}: Props) => { const [appNotFound, setAppNotFound] = useState(false); const elementRef = useRef(null); const unmountRef: MutableRefObject = useRef(null); @@ -59,13 +64,14 @@ export const AppContainer: FunctionComponent = ({ mounter, appId }: Props (await mounter.mount({ appBasePath: mounter.appBasePath, element: elementRef.current!, + onAppLeave: handler => setAppLeaveHandler(appId, handler), })) || null; setAppNotFound(false); }; mount(); return unmount; - }, [mounter]); + }, [appId, mounter, setAppLeaveHandler]); return ( diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 8db46f97942779..2ee90c3bf5e29e 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -21,19 +21,20 @@ import React, { FunctionComponent } from 'react'; import { History } from 'history'; import { Router, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { Mounter } from '../types'; +import { Mounter, AppLeaveHandler } from '../types'; import { AppContainer } from './app_container'; interface Props { mounters: Map; history: History; + setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; } interface Params { appId: string; } -export const AppRouter: FunctionComponent = ({ history, mounters }) => ( +export const AppRouter: FunctionComponent = ({ history, mounters, setAppLeaveHandler }) => ( {[...mounters].flatMap(([appId, mounter]) => @@ -45,7 +46,13 @@ export const AppRouter: FunctionComponent = ({ history, mounters }) => ( } + render={() => ( + + )} />, ] )} @@ -61,7 +68,9 @@ export const AppRouter: FunctionComponent = ({ history, mounters }) => ( ? [appId, mounters.get(appId)] : [...mounters].filter(([key]) => key.split(':')[0] === appId)[0] ?? []; - return ; + return ( + + ); }} /> diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index d9c35b20db03bb..abd04722a49f20 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -18,7 +18,7 @@ */ import * as Rx from 'rxjs'; -import { toArray } from 'rxjs/operators'; +import { take, toArray } from 'rxjs/operators'; import { shallow } from 'enzyme'; import React from 'react'; @@ -54,7 +54,9 @@ function defaultStartDeps(availableApps?: App[]) { }; if (availableApps) { - deps.application.availableApps = new Map(availableApps.map(app => [app.id, app])); + deps.application.applications$ = new Rx.BehaviorSubject>( + new Map(availableApps.map(app => [app.id, app])) + ); } return deps; @@ -211,13 +213,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, navigateToApp } = startDeps.application; + const { applications$, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); + const availableApps = await applications$.pipe(take(1)).toPromise(); [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 18c0c9870d72fd..a674b49a8e1344 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { BehaviorSubject, Observable, ReplaySubject, combineLatest, of, merge } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { i18n } from '@kbn/i18n'; @@ -118,11 +118,12 @@ export class ChromeService { // combineLatest below regardless of having an application value yet. of(isEmbedded), application.currentAppId$.pipe( - map( - appId => - !!appId && - application.availableApps.has(appId) && - !!application.availableApps.get(appId)!.chromeless + flatMap(appId => + application.applications$.pipe( + map(applications => { + return !!appId && applications.has(appId) && !!applications.get(appId)!.chromeless; + }) + ) ) ) ); diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index d87d171e028e17..3b16c030ddcc93 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -63,7 +63,7 @@ export interface ChromeNavLink { /** LEGACY FIELDS */ /** - * A url base that legacy apps can set to match deep URLs to an applcation. + * A url base that legacy apps can set to match deep URLs to an application. * * @internalRemarks * This should be removed once legacy apps are gone. diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 5a45491df28e7b..3d9a4bfdb6a56c 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -20,34 +20,47 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App, LegacyApp } from '../../application'; +import { BehaviorSubject } from 'rxjs'; -const mockAppService = { - availableApps: new Map( - ([ - { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }, - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }, - ] as App[]).map(app => [app.id, app]) - ), - availableLegacyApps: new Map( - ([ - { id: 'legacyApp1', order: 5, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, - { - id: 'legacyApp2', - order: -5, - title: 'Legacy App 2', - euiIconType: 'canvasApp', - appUrl: '/app2', - }, - { id: 'legacyApp3', order: 15, title: 'Legacy App 3', appUrl: '/app3' }, - ] as LegacyApp[]).map(app => [app.id, app]) - ), -} as any; +const availableApps = new Map([ + ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], + ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], + [ + 'legacyApp1', + { + id: 'legacyApp1', + order: 5, + title: 'Legacy App 1', + icon: 'legacyApp1', + appUrl: '/app1', + legacy: true, + }, + ], + [ + 'legacyApp2', + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + legacy: true, + }, + ], + [ + 'legacyApp3', + { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3', legacy: true }, + ], +]); const mockHttp = { basePath: { @@ -57,10 +70,16 @@ const mockHttp = { describe('NavLinksService', () => { let service: NavLinksService; + let mockAppService: any; let start: ReturnType; beforeEach(() => { service = new NavLinksService(); + mockAppService = { + applications$: new BehaviorSubject>( + availableApps as any + ), + }; start = service.start({ application: mockAppService, http: mockHttp }); }); @@ -183,22 +202,36 @@ describe('NavLinksService', () => { .toPromise() ).toEqual(['legacyApp1']); }); + + it('still removes all other links when availableApps are re-emitted', async () => { + start.showOnly('legacyApp2'); + mockAppService.applications$.next(mockAppService.applications$.value); + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.map(l => l.id)) + ) + .toPromise() + ).toEqual(['legacyApp2']); + }); }); describe('#update()', () => { it('updates the navlinks and returns the updated link', async () => { - expect(start.update('legacyApp1', { hidden: true })).toMatchInlineSnapshot(` - Object { - "appUrl": "/app1", - "baseUrl": "http://localhost/wow/app1", - "hidden": true, - "icon": "legacyApp1", - "id": "legacyApp1", - "legacy": true, - "order": 5, - "title": "Legacy App 1", - } - `); + expect(start.update('legacyApp1', { hidden: true })).toEqual( + expect.objectContaining({ + appUrl: '/app1', + disabled: false, + hidden: true, + icon: 'legacyApp1', + id: 'legacyApp1', + legacy: true, + order: 5, + title: 'Legacy App 1', + }) + ); const hiddenLinkIds = await start .getNavLinks$() .pipe( @@ -212,6 +245,19 @@ describe('NavLinksService', () => { it('returns undefined if link does not exist', () => { expect(start.update('fake', { hidden: true })).toBeUndefined(); }); + + it('keeps the updated link when availableApps are re-emitted', async () => { + start.update('legacyApp1', { hidden: true }); + mockAppService.applications$.next(mockAppService.applications$.value); + const hiddenLinkIds = await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.filter(l => l.hidden).map(l => l.id)) + ) + .toPromise(); + expect(hiddenLinkIds).toEqual(['legacyApp1']); + }); }); describe('#enableForcedAppSwitcherNavigation()', () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 31a729f90cd932..650ef77b6fe42e 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -18,11 +18,13 @@ */ import { sortBy } from 'lodash'; -import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { NavLinkWrapper, ChromeNavLinkUpdateableFields, ChromeNavLink } from './nav_link'; + import { InternalApplicationStart } from '../../application'; import { HttpStart } from '../../http'; +import { ChromeNavLink, ChromeNavLinkUpdateableFields, NavLinkWrapper } from './nav_link'; +import { toNavLink } from './to_nav_link'; interface StartDeps { application: InternalApplicationStart; @@ -95,39 +97,38 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } +type LinksUpdater = (navLinks: Map) => Map; + export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { - const appLinks = [...application.availableApps] - .filter(([, app]) => !app.chromeless) - .map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: false, - baseUrl: relativeToAbsolute(http.basePath.prepend(`/app/${appId}`)), - }), - ] as [string, NavLinkWrapper] - ); - - const legacyAppLinks = [...application.availableLegacyApps].map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: true, - baseUrl: relativeToAbsolute(http.basePath.prepend(app.appUrl)), - }), - ] as [string, NavLinkWrapper] + const appLinks$ = application.applications$.pipe( + map(apps => { + return new Map( + [...apps] + .filter(([, app]) => !app.chromeless) + .map(([appId, app]) => [appId, toNavLink(app, http.basePath)]) + ); + }) ); - const navLinks$ = new BehaviorSubject>( - new Map([...legacyAppLinks, ...appLinks]) - ); + // now that availableApps$ is an observable, we need to keep record of all + // manual link modifications to be able to re-apply then after every + // availableApps$ changes. + const linkUpdaters$ = new BehaviorSubject([]); + const navLinks$ = new BehaviorSubject>(new Map()); + + combineLatest([appLinks$, linkUpdaters$]) + .pipe( + map(([appLinks, linkUpdaters]) => { + return linkUpdaters.reduce((links, updater) => updater(links), appLinks); + }) + ) + .subscribe(navlinks => { + navLinks$.next(navlinks); + }); + const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { @@ -153,7 +154,10 @@ export class NavLinksService { return; } - navLinks$.next(new Map([...navLinks$.value.entries()].filter(([linkId]) => linkId === id))); + const updater: LinksUpdater = navLinks => + new Map([...navLinks.entries()].filter(([linkId]) => linkId === id)); + + linkUpdaters$.next([...linkUpdaters$.value, updater]); }, update(id: string, values: ChromeNavLinkUpdateableFields) { @@ -161,17 +165,17 @@ export class NavLinksService { return; } - navLinks$.next( + const updater: LinksUpdater = navLinks => new Map( - [...navLinks$.value.entries()].map(([linkId, link]) => { + [...navLinks.entries()].map(([linkId, link]) => { return [linkId, link.id === id ? link.update(values) : link] as [ string, NavLinkWrapper ]; }) - ) - ); + ); + linkUpdaters$.next([...linkUpdaters$.value, updater]); return this.get(id); }, @@ -196,10 +200,3 @@ function sortNavLinks(navLinks: ReadonlyMap) { 'order' ); } - -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts new file mode 100644 index 00000000000000..23fdabe0f34301 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppMount, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { toNavLink } from './to_nav_link'; + +import { httpServiceMock } from '../../mocks'; + +function mount() {} + +const app = (props: Partial = {}): App => ({ + mount: (mount as unknown) as AppMount, + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + appRoute: `/app/some-id`, + legacy: false, + ...props, +}); + +const legacyApp = (props: Partial = {}): LegacyApp => ({ + appUrl: '/my-app-url', + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + legacy: true, + ...props, +}); + +describe('toNavLink', () => { + const basePath = httpServiceMock.createSetupContract({ basePath: '/base-path' }).basePath; + + it('uses the application properties when creating the navLink', () => { + const link = toNavLink( + app({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }), + basePath + ); + expect(link.properties).toEqual( + expect.objectContaining({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }) + ); + }); + + it('flags legacy apps when converting to navLink', () => { + expect(toNavLink(app({}), basePath).properties.legacy).toEqual(false); + expect(toNavLink(legacyApp({}), basePath).properties.legacy).toEqual(true); + }); + + it('handles applications with custom app route', () => { + const link = toNavLink( + app({ + appRoute: '/my-route/my-path', + }), + basePath + ); + expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path'); + }); + + it('uses appUrl when converting legacy applications', () => { + expect( + toNavLink( + legacyApp({ + appUrl: '/my-legacy-app/#foo', + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + baseUrl: 'http://localhost/base-path/my-legacy-app/#foo', + }) + ); + }); + + it('uses the application status when the navLinkStatus is set to default', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + }); + + it('uses the navLinkStatus of the application to set the hidden and disabled properties', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.visible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.hidden, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.disabled, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: true, + hidden: false, + }) + ); + }); +}); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts new file mode 100644 index 00000000000000..18e4b7b26b6ba1 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { IBasePath } from '../../http'; +import { NavLinkWrapper } from './nav_link'; + +export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { + const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; + return new NavLinkWrapper({ + ...app, + hidden: useAppStatus + ? app.status === AppStatus.inaccessible + : app.navLinkStatus === AppNavLinkStatus.hidden, + disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, + legacy: isLegacyApp(app), + baseUrl: isLegacyApp(app) + ? relativeToAbsolute(basePath.prepend(app.appUrl)) + : relativeToAbsolute(basePath.prepend(app.appRoute!)), + }); +} + +function relativeToAbsolute(url: string) { + // convert all link urls to absolute urls + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} + +function isLegacyApp(app: App | LegacyApp): app is LegacyApp { + return app.legacy === true; +} diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 75f78ac8b2fa00..0447add4917882 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -309,7 +309,7 @@ class HeaderUI extends Component { .filter(navLink => !navLink.hidden) .map(navLink => ({ key: navLink.id, - label: navLink.title, + label: navLink.tooltip ?? navLink.title, // Use href and onClick to support "open in new tab" and SPA navigation in the same link href: navLink.href, diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 485c11aae6508c..5b31c740518e4a 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -214,7 +214,6 @@ export class CoreSystem { const http = await this.http.start({ injectedMetadata, fatalErrors: this.fatalErrorsSetup! }); const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); - const application = await this.application.start({ http, injectedMetadata }); await this.integrations.start({ uiSettings }); const coreUiTargetDomElement = document.createElement('div'); @@ -239,6 +238,7 @@ export class CoreSystem { overlays, targetDomElement: notificationsTargetDomElement, }); + const application = await this.application.start({ http, overlays }); const chrome = await this.chrome.start({ application, docLinks, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 7488f9b973b712..5b17eccc37f8b8 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -89,6 +89,15 @@ export { AppUnmount, AppMountContext, AppMountParameters, + AppLeaveHandler, + AppLeaveActionType, + AppLeaveAction, + AppLeaveDefaultAction, + AppLeaveConfirmAction, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, } from './application'; export { diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index a4fdd86de53112..f906aff1759e2e 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -81,6 +81,7 @@ export class LegacyPlatformService { ...core, getStartServices: () => this.startDependencies, application: { + ...core.application, register: notSupported(`core.application.register()`), registerMountContext: notSupported(`core.application.registerMountContext()`), }, diff --git a/src/core/public/notifications/toasts/global_toast_list.test.tsx b/src/core/public/notifications/toasts/global_toast_list.test.tsx index 61d73ac2331886..dc2a9dabe791e9 100644 --- a/src/core/public/notifications/toasts/global_toast_list.test.tsx +++ b/src/core/public/notifications/toasts/global_toast_list.test.tsx @@ -57,9 +57,9 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { it('passes latest value from toasts$ to ', () => { const el = shallow( render({ - toasts$: Rx.from([[], [{ id: 1 }], [{ id: 1 }, { id: 2 }]]) as any, + toasts$: Rx.from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, }) ); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: 1 }, { id: 2 }]); + expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); }); diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap index 131ec836f52524..7eaa1c3af2079b 100644 --- a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -8,6 +8,129 @@ Array [ ] `; +exports[`ModalService openConfirm() renders a mountpoint confirm message 1`] = ` +Array [ + Array [ + + + + + + + , +
, + ], +] +`; + +exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = `"
Modal content
"`; + +exports[`ModalService openConfirm() renders a string confirm message 1`] = ` +Array [ + Array [ + + + + Some message + + + , +
, + ], +] +`; + +exports[`ModalService openConfirm() renders a string confirm message 2`] = `"

Some message

"`; + +exports[`ModalService openConfirm() with a currently active confirm replaces the current confirm with the new one 1`] = ` +Array [ + Array [ + + + + confirm 1 + + + , +
, + ], + Array [ + + + + some confirm + + + , +
, + ], +] +`; + +exports[`ModalService openConfirm() with a currently active modal replaces the current modal with the new confirm 1`] = ` +Array [ + Array [ + + + + + + + , +
, + ], + Array [ + + + + some confirm + + + , +
, + ], +] +`; + exports[`ModalService openModal() renders a modal to the DOM 1`] = ` Array [ Array [ @@ -31,6 +154,43 @@ Array [ exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
Modal content
"`; +exports[`ModalService openModal() with a currently active confirm replaces the current confirm with the new one 1`] = ` +Array [ + Array [ + + + + confirm 1 + + + , +
, + ], + Array [ + + + + some confirm + + + , +
, + ], +] +`; + exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = ` Array [ Array [ diff --git a/src/core/public/overlays/modal/modal_service.mock.ts b/src/core/public/overlays/modal/modal_service.mock.ts index 726209b8f277cd..5ac49874dcf933 100644 --- a/src/core/public/overlays/modal/modal_service.mock.ts +++ b/src/core/public/overlays/modal/modal_service.mock.ts @@ -25,6 +25,7 @@ const createStartContractMock = () => { close: jest.fn(), onClose: Promise.resolve(), }), + openConfirm: jest.fn().mockResolvedValue(true), }; return startContract; }; diff --git a/src/core/public/overlays/modal/modal_service.test.tsx b/src/core/public/overlays/modal/modal_service.test.tsx index 582c2697aef300..8b68075bb2a001 100644 --- a/src/core/public/overlays/modal/modal_service.test.tsx +++ b/src/core/public/overlays/modal/modal_service.test.tsx @@ -80,6 +80,91 @@ describe('ModalService', () => { expect(onCloseComplete).toBeCalledTimes(1); }); }); + + describe('with a currently active confirm', () => { + let confirm1: Promise; + + beforeEach(() => { + confirm1 = modals.openConfirm('confirm 1'); + }); + + it('replaces the current confirm with the new one', () => { + modals.openConfirm('some confirm'); + expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + expect(mockReactDomUnmount).toHaveBeenCalledTimes(1); + }); + + it('resolves the previous confirm promise', async () => { + modals.open(mountReactNode(Flyout content 2)); + expect(await confirm1).toEqual(false); + }); + }); + }); + + describe('openConfirm()', () => { + it('renders a mountpoint confirm message', () => { + expect(mockReactDomRender).not.toHaveBeenCalled(); + modals.openConfirm(container => { + const content = document.createElement('span'); + content.textContent = 'Modal content'; + container.append(content); + return () => {}; + }); + expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + const modalContent = mount(mockReactDomRender.mock.calls[0][0]); + expect(modalContent.html()).toMatchSnapshot(); + }); + + it('renders a string confirm message', () => { + expect(mockReactDomRender).not.toHaveBeenCalled(); + modals.openConfirm('Some message'); + expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + const modalContent = mount(mockReactDomRender.mock.calls[0][0]); + expect(modalContent.html()).toMatchSnapshot(); + }); + + describe('with a currently active modal', () => { + let ref1: OverlayRef; + + beforeEach(() => { + ref1 = modals.open(mountReactNode(Modal content 1)); + }); + + it('replaces the current modal with the new confirm', () => { + modals.openConfirm('some confirm'); + expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + expect(mockReactDomUnmount).toHaveBeenCalledTimes(1); + expect(() => ref1.close()).not.toThrowError(); + expect(mockReactDomUnmount).toHaveBeenCalledTimes(1); + }); + + it('resolves onClose on the previous ref', async () => { + const onCloseComplete = jest.fn(); + ref1.onClose.then(onCloseComplete); + modals.openConfirm('some confirm'); + await ref1.onClose; + expect(onCloseComplete).toBeCalledTimes(1); + }); + }); + + describe('with a currently active confirm', () => { + let confirm1: Promise; + + beforeEach(() => { + confirm1 = modals.openConfirm('confirm 1'); + }); + + it('replaces the current confirm with the new one', () => { + modals.openConfirm('some confirm'); + expect(mockReactDomRender.mock.calls).toMatchSnapshot(); + expect(mockReactDomUnmount).toHaveBeenCalledTimes(1); + }); + + it('resolves the previous confirm promise', async () => { + modals.openConfirm('some confirm'); + expect(await confirm1).toEqual(false); + }); + }); }); describe('ModalRef#close()', () => { diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index cb77c2ec4c88ca..ba7887b1afa5cf 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -19,7 +19,8 @@ /* eslint-disable max-classes-per-file */ -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n as t } from '@kbn/i18n'; +import { EuiModal, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -57,6 +58,18 @@ class ModalRef implements OverlayRef { } } +/** + * @public + */ +export interface OverlayModalConfirmOptions { + title?: string; + cancelButtonText?: string; + confirmButtonText?: string; + className?: string; + closeButtonAriaLabel?: string; + 'data-test-subj'?: string; +} + /** * APIs to open and manage modal dialogs. * @@ -72,6 +85,14 @@ export interface OverlayModalStart { * @return {@link OverlayRef} A reference to the opened modal. */ open(mount: MountPoint, options?: OverlayModalOpenOptions): OverlayRef; + /** + * Opens a confirmation modal with the given text or mountpoint as a message. + * Returns a Promise resolving to `true` if user confirmed or `false` otherwise. + * + * @param message {@link MountPoint} - string or mountpoint to be used a the confirm message body + * @param options {@link OverlayModalConfirmOptions} - options for the confirm modal + */ + openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise; } /** @@ -98,7 +119,7 @@ export class ModalService { return { open: (mount: MountPoint, options: OverlayModalOpenOptions = {}): OverlayRef => { - // If there is an active flyout session close it before opening a new one. + // If there is an active modal, close it before opening a new one. if (this.activeModal) { this.activeModal.close(); this.cleanupDom(); @@ -128,6 +149,65 @@ export class ModalService { return modal; }, + openConfirm: (message: MountPoint | string, options?: OverlayModalConfirmOptions) => { + // If there is an active modal, close it before opening a new one. + if (this.activeModal) { + this.activeModal.close(); + this.cleanupDom(); + } + + return new Promise((resolve, reject) => { + let resolved = false; + const closeModal = (confirmed: boolean) => { + resolved = true; + modal.close(); + resolve(confirmed); + }; + + const modal = new ModalRef(); + modal.onClose.then(() => { + if (this.activeModal === modal) { + this.cleanupDom(); + } + // modal.close can be called when opening a new modal/confirm, so we need to resolve the promise in that case. + if (!resolved) { + closeModal(false); + } + }); + this.activeModal = modal; + + const props = { + ...options, + children: + typeof message === 'string' ? ( + message + ) : ( + + ), + onCancel: () => closeModal(false), + onConfirm: () => closeModal(true), + cancelButtonText: + options?.cancelButtonText || + t.translate('core.overlays.confirm.cancelButton', { + defaultMessage: 'Cancel', + }), + confirmButtonText: + options?.confirmButtonText || + t.translate('core.overlays.confirm.okButton', { + defaultMessage: 'Confirm', + }), + }; + + render( + + + + + , + targetDomElement + ); + }); + }, }; } diff --git a/src/core/public/overlays/overlay_service.mock.ts b/src/core/public/overlays/overlay_service.mock.ts index 2937ec89bfc744..e29247494034f9 100644 --- a/src/core/public/overlays/overlay_service.mock.ts +++ b/src/core/public/overlays/overlay_service.mock.ts @@ -22,9 +22,11 @@ import { overlayFlyoutServiceMock } from './flyout/flyout_service.mock'; import { overlayModalServiceMock } from './modal/modal_service.mock'; const createStartContractMock = () => { + const overlayStart = overlayModalServiceMock.createStartContract(); const startContract: DeeplyMockedKeys = { openFlyout: overlayFlyoutServiceMock.createStartContract().open, - openModal: overlayModalServiceMock.createStartContract().open, + openModal: overlayStart.open, + openConfirm: overlayStart.openConfirm, banners: overlayBannersServiceMock.createStartContract(), }; return startContract; diff --git a/src/core/public/overlays/overlay_service.ts b/src/core/public/overlays/overlay_service.ts index f628182e965d86..2ff43ba3fbf277 100644 --- a/src/core/public/overlays/overlay_service.ts +++ b/src/core/public/overlays/overlay_service.ts @@ -50,6 +50,7 @@ export class OverlayService { banners, openFlyout: flyouts.open.bind(flyouts), openModal: modals.open.bind(modals), + openConfirm: modals.openConfirm.bind(modals), }; } } @@ -62,4 +63,6 @@ export interface OverlayStart { openFlyout: OverlayFlyoutStart['open']; /** {@link OverlayModalStart#open} */ openModal: OverlayModalStart['open']; + /** {@link OverlayModalStart#openConfirm} */ + openConfirm: OverlayModalStart['openConfirm']; } diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 848f46605d4deb..f146c2452868b4 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -96,6 +96,7 @@ export function createPluginSetupContext< return { application: { register: app => deps.application.register(plugin.opaqueId, app), + registerAppUpdater: statusUpdater$ => deps.application.registerAppUpdater(statusUpdater$), registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 281778f9420dd6..cafc7e5887e385 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -279,6 +279,37 @@ describe('PluginsService', () => { expect((contracts.get('pluginA')! as any).setupValue).toEqual(1); expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2); }); + + describe('timeout', () => { + const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it('throws timeout error if "setup" was not completed in 30 sec.', async () => { + mockPluginInitializers.set( + 'pluginA', + jest.fn(() => ({ + setup: jest.fn(() => new Promise(i => i)), + start: jest.fn(() => ({ value: 1 })), + stop: jest.fn(), + })) + ); + const pluginsService = new PluginsService(mockCoreContext, plugins); + const promise = pluginsService.setup(mockSetupDeps); + + jest.runAllTimers(); // load plugin bundles + await flushPromises(); + jest.runAllTimers(); // setup plugins + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Setup lifecycle of "pluginA" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); + }); }); describe('#start()', () => { @@ -331,6 +362,34 @@ describe('PluginsService', () => { expect((contracts.get('pluginA')! as any).startValue).toEqual(2); expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(3); }); + describe('timeout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it('throws timeout error if "start" was not completed in 30 sec.', async () => { + mockPluginInitializers.set( + 'pluginA', + jest.fn(() => ({ + setup: jest.fn(() => ({ value: 1 })), + start: jest.fn(() => new Promise(i => i)), + stop: jest.fn(), + })) + ); + const pluginsService = new PluginsService(mockCoreContext, plugins); + await pluginsService.setup(mockSetupDeps); + + const promise = pluginsService.start(mockStartDeps); + jest.runAllTimers(); + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Start lifecycle of "pluginA" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); + }); }); describe('#stop()', () => { diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index c1939a33976474..8e1574d05baf8e 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -28,7 +28,9 @@ import { } from './plugin_context'; import { InternalCoreSetup, InternalCoreStart } from '../core_system'; import { InjectedPluginMetadata } from '../injected_metadata'; +import { withTimeout } from '../../utils'; +const Sec = 1000; /** @internal */ export type PluginsServiceSetupDeps = InternalCoreSetup; /** @internal */ @@ -110,13 +112,15 @@ export class PluginsService implements CoreService ); - contracts.set( - pluginName, - await plugin.setup( + const contract = await withTimeout({ + promise: plugin.setup( createPluginSetupContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Setup lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + contracts.set(pluginName, contract); this.satupPlugins.push(pluginName); } @@ -142,13 +146,15 @@ export class PluginsService implements CoreService ); - contracts.set( - pluginName, - await plugin.start( + const contract = await withTimeout({ + promise: plugin.start( createPluginStartContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Start lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + contracts.set(pluginName, contract); } // Expose start contracts diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index f61741571dc1dc..aef689162f45a2 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1,1114 +1,1179 @@ -## API Report File for "kibana" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { Breadcrumb } from '@elastic/eui'; -import { EuiButtonEmptyProps } from '@elastic/eui'; -import { EuiGlobalToastListToast } from '@elastic/eui'; -import { ExclusiveUnion } from '@elastic/eui'; -import { IconType } from '@elastic/eui'; -import { Observable } from 'rxjs'; -import React from 'react'; -import * as Rx from 'rxjs'; -import { ShallowPromise } from '@kbn/utility-types'; -import { UiSettingsParams as UiSettingsParams_2 } from 'src/core/server/types'; -import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types'; - -// @public -export interface App extends AppBase { - appRoute?: string; - chromeless?: boolean; - mount: AppMount | AppMountDeprecated; -} - -// @public (undocumented) -export interface AppBase { - capabilities?: Partial; - euiIconType?: string; - icon?: string; - // (undocumented) - id: string; - order?: number; - title: string; - tooltip$?: Observable; -} - -// @public (undocumented) -export interface ApplicationSetup { - register(app: App): void; - // @deprecated - registerMountContext(contextName: T, provider: IContextProvider): void; -} - -// @public (undocumented) -export interface ApplicationStart { - capabilities: RecursiveReadonly; - getUrlForApp(appId: string, options?: { - path?: string; - }): string; - navigateToApp(appId: string, options?: { - path?: string; - state?: any; - }): void; - // @deprecated - registerMountContext(contextName: T, provider: IContextProvider): void; -} - -// @public -export type AppMount = (params: AppMountParameters) => AppUnmount | Promise; - -// @public @deprecated -export interface AppMountContext { - core: { - application: Pick; - chrome: ChromeStart; - docLinks: DocLinksStart; - http: HttpStart; - i18n: I18nStart; - notifications: NotificationsStart; - overlays: OverlayStart; - savedObjects: SavedObjectsStart; - uiSettings: IUiSettingsClient; - injectedMetadata: { - getInjectedVar: (name: string, defaultValue?: any) => unknown; - }; - }; -} - -// @public @deprecated -export type AppMountDeprecated = (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; - -// @public (undocumented) -export interface AppMountParameters { - appBasePath: string; - element: HTMLElement; -} - -// @public -export type AppUnmount = () => void; - -// @public -export interface Capabilities { - [key: string]: Record>; - catalogue: Record; - management: { - [sectionId: string]: Record; - }; - navLinks: Record; -} - -// @public (undocumented) -export interface ChromeBadge { - // (undocumented) - iconType?: IconType; - // (undocumented) - text: string; - // (undocumented) - tooltip: string; -} - -// @public (undocumented) -export interface ChromeBrand { - // (undocumented) - logo?: string; - // (undocumented) - smallLogo?: string; -} - -// @public (undocumented) -export type ChromeBreadcrumb = Breadcrumb; - -// @public -export interface ChromeDocTitle { - // @internal (undocumented) - __legacy: { - setBaseTitle(baseTitle: string): void; - }; - change(newTitle: string | string[]): void; - reset(): void; -} - -// @public (undocumented) -export interface ChromeHelpExtension { - appName: string; - content?: (element: HTMLDivElement) => () => void; - links?: ChromeHelpExtensionMenuLink[]; -} - -// @public (undocumented) -export type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { - linkType: 'custom'; - content: React.ReactNode; -}; - -// @public (undocumented) -export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { - linkType: 'discuss'; - href: string; -}; - -// @public (undocumented) -export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { - linkType: 'documentation'; - href: string; -}; - -// @public (undocumented) -export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { - linkType: 'github'; - labels: string[]; - title?: string; -}; - -// @public (undocumented) -export type ChromeHelpExtensionMenuLink = ExclusiveUnion>>; - -// @public (undocumented) -export interface ChromeNavControl { - // (undocumented) - mount: MountPoint; - // (undocumented) - order?: number; -} - -// @public -export interface ChromeNavControls { - // @internal (undocumented) - getLeft$(): Observable; - // @internal (undocumented) - getRight$(): Observable; - registerLeft(navControl: ChromeNavControl): void; - registerRight(navControl: ChromeNavControl): void; -} - -// @public (undocumented) -export interface ChromeNavLink { - // @deprecated - readonly active?: boolean; - readonly baseUrl: string; - // @deprecated - readonly disabled?: boolean; - readonly euiIconType?: string; - readonly hidden?: boolean; - readonly icon?: string; - readonly id: string; - // @internal - readonly legacy: boolean; - // @deprecated - readonly linkToLastSubUrl?: boolean; - readonly order?: number; - // @deprecated - readonly subUrlBase?: string; - readonly title: string; - readonly tooltip?: string; - // @deprecated - readonly url?: string; -} - -// @public -export interface ChromeNavLinks { - enableForcedAppSwitcherNavigation(): void; - get(id: string): ChromeNavLink | undefined; - getAll(): Array>; - getForceAppSwitcherNavigation$(): Observable; - getNavLinks$(): Observable>>; - has(id: string): boolean; - showOnly(id: string): void; - update(id: string, values: ChromeNavLinkUpdateableFields): ChromeNavLink | undefined; -} - -// @public (undocumented) -export type ChromeNavLinkUpdateableFields = Partial>; - -// @public -export interface ChromeRecentlyAccessed { - // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "basePath" - add(link: string, label: string, id: string): void; - get$(): Observable; - get(): ChromeRecentlyAccessedHistoryItem[]; -} - -// @public (undocumented) -export interface ChromeRecentlyAccessedHistoryItem { - // (undocumented) - id: string; - // (undocumented) - label: string; - // (undocumented) - link: string; -} - -// @public -export interface ChromeStart { - addApplicationClass(className: string): void; - docTitle: ChromeDocTitle; - getApplicationClasses$(): Observable; - getBadge$(): Observable; - getBrand$(): Observable; - getBreadcrumbs$(): Observable; - getHelpExtension$(): Observable; - getIsCollapsed$(): Observable; - getIsVisible$(): Observable; - navControls: ChromeNavControls; - navLinks: ChromeNavLinks; - recentlyAccessed: ChromeRecentlyAccessed; - removeApplicationClass(className: string): void; - setAppTitle(appTitle: string): void; - setBadge(badge?: ChromeBadge): void; - setBrand(brand: ChromeBrand): void; - setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; - setHelpExtension(helpExtension?: ChromeHelpExtension): void; - setHelpSupportUrl(url: string): void; - setIsCollapsed(isCollapsed: boolean): void; - setIsVisible(isVisible: boolean): void; -} - -// @public -export interface ContextSetup { - createContextContainer>(): IContextContainer; -} - -// @internal (undocumented) -export interface CoreContext { - // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts - // - // (undocumented) - coreId: CoreId; - // (undocumented) - env: { - mode: Readonly; - packageInfo: Readonly; - }; -} - -// @public -export interface CoreSetup { - // (undocumented) - application: ApplicationSetup; - // @deprecated (undocumented) - context: ContextSetup; - // (undocumented) - fatalErrors: FatalErrorsSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; - // (undocumented) - http: HttpSetup; - // @deprecated - injectedMetadata: { - getInjectedVar: (name: string, defaultValue?: any) => unknown; - }; - // (undocumented) - notifications: NotificationsSetup; - // (undocumented) - uiSettings: IUiSettingsClient; -} - -// @public -export interface CoreStart { - // (undocumented) - application: ApplicationStart; - // (undocumented) - chrome: ChromeStart; - // (undocumented) - docLinks: DocLinksStart; - // (undocumented) - http: HttpStart; - // (undocumented) - i18n: I18nStart; - // @deprecated - injectedMetadata: { - getInjectedVar: (name: string, defaultValue?: any) => unknown; - }; - // (undocumented) - notifications: NotificationsStart; - // (undocumented) - overlays: OverlayStart; - // (undocumented) - savedObjects: SavedObjectsStart; - // (undocumented) - uiSettings: IUiSettingsClient; -} - -// @internal -export class CoreSystem { - // Warning: (ae-forgotten-export) The symbol "Params" needs to be exported by the entry point index.d.ts - constructor(params: Params); - // (undocumented) - setup(): Promise<{ - fatalErrors: FatalErrorsSetup; - } | undefined>; - // (undocumented) - start(): Promise; - // (undocumented) - stop(): void; - } - -// @public (undocumented) -export interface DocLinksStart { - // (undocumented) - readonly DOC_LINK_VERSION: string; - // (undocumented) - readonly ELASTIC_WEBSITE_URL: string; - // (undocumented) - readonly links: { - readonly filebeat: { - readonly base: string; - readonly installation: string; - readonly configuration: string; - readonly elasticsearchOutput: string; - readonly startup: string; - readonly exportedFields: string; - }; - readonly auditbeat: { - readonly base: string; - }; - readonly metricbeat: { - readonly base: string; - }; - readonly heartbeat: { - readonly base: string; - }; - readonly logstash: { - readonly base: string; - }; - readonly functionbeat: { - readonly base: string; - }; - readonly winlogbeat: { - readonly base: string; - }; - readonly aggs: { - readonly date_histogram: string; - readonly date_range: string; - readonly filter: string; - readonly filters: string; - readonly geohash_grid: string; - readonly histogram: string; - readonly ip_range: string; - readonly range: string; - readonly significant_terms: string; - readonly terms: string; - readonly avg: string; - readonly avg_bucket: string; - readonly max_bucket: string; - readonly min_bucket: string; - readonly sum_bucket: string; - readonly cardinality: string; - readonly count: string; - readonly cumulative_sum: string; - readonly derivative: string; - readonly geo_bounds: string; - readonly geo_centroid: string; - readonly max: string; - readonly median: string; - readonly min: string; - readonly moving_avg: string; - readonly percentile_ranks: string; - readonly serial_diff: string; - readonly std_dev: string; - readonly sum: string; - readonly top_hits: string; - }; - readonly scriptedFields: { - readonly scriptFields: string; - readonly scriptAggs: string; - readonly painless: string; - readonly painlessApi: string; - readonly painlessSyntax: string; - readonly luceneExpressions: string; - }; - readonly indexPatterns: { - readonly loadingData: string; - readonly introduction: string; - }; - readonly kibana: string; - readonly siem: string; - readonly query: { - readonly luceneQuerySyntax: string; - readonly queryDsl: string; - readonly kueryQuerySyntax: string; - }; - readonly date: { - readonly dateMath: string; - }; - }; -} - -// @public (undocumented) -export interface EnvironmentMode { - // (undocumented) - dev: boolean; - // (undocumented) - name: 'development' | 'production'; - // (undocumented) - prod: boolean; -} - -// @public -export interface ErrorToastOptions { - title: string; - toastMessage?: string; -} - -// @public -export interface FatalErrorInfo { - // (undocumented) - message: string; - // (undocumented) - stack: string | undefined; -} - -// @public -export interface FatalErrorsSetup { - add: (error: string | Error, source?: string) => never; - get$: () => Rx.Observable; -} - -// @public -export type HandlerContextType> = T extends HandlerFunction ? U : never; - -// @public -export type HandlerFunction = (context: T, ...args: any[]) => any; - -// @public -export type HandlerParameters> = T extends (context: any, ...args: infer U) => any ? U : never; - -// @public (undocumented) -export interface HttpErrorRequest { - // (undocumented) - error: Error; - // (undocumented) - request: Request; -} - -// @public (undocumented) -export interface HttpErrorResponse extends IHttpResponse { - // (undocumented) - error: Error | IHttpFetchError; -} - -// @public -export interface HttpFetchOptions extends HttpRequestInit { - asResponse?: boolean; - headers?: HttpHeadersInit; - prependBasePath?: boolean; - query?: HttpFetchQuery; -} - -// @public (undocumented) -export interface HttpFetchQuery { - // (undocumented) - [key: string]: string | number | boolean | undefined; -} - -// @public -export interface HttpHandler { - // (undocumented) - (path: string, options: HttpFetchOptions & { - asResponse: true; - }): Promise>; - // (undocumented) - (path: string, options?: HttpFetchOptions): Promise; -} - -// @public (undocumented) -export interface HttpHeadersInit { - // (undocumented) - [name: string]: any; -} - -// @public -export interface HttpInterceptor { - request?(request: Request, controller: IHttpInterceptController): Promise | Request | void; - requestError?(httpErrorRequest: HttpErrorRequest, controller: IHttpInterceptController): Promise | Request | void; - response?(httpResponse: IHttpResponse, controller: IHttpInterceptController): Promise | IHttpResponseInterceptorOverrides | void; - responseError?(httpErrorResponse: HttpErrorResponse, controller: IHttpInterceptController): Promise | IHttpResponseInterceptorOverrides | void; -} - -// @public -export interface HttpRequestInit { - body?: BodyInit | null; - cache?: RequestCache; - credentials?: RequestCredentials; - // (undocumented) - headers?: HttpHeadersInit; - integrity?: string; - keepalive?: boolean; - method?: string; - mode?: RequestMode; - redirect?: RequestRedirect; - referrer?: string; - referrerPolicy?: ReferrerPolicy; - signal?: AbortSignal | null; - window?: null; -} - -// @public (undocumented) -export interface HttpSetup { - addLoadingCountSource(countSource$: Observable): void; - anonymousPaths: IAnonymousPaths; - basePath: IBasePath; - delete: HttpHandler; - fetch: HttpHandler; - get: HttpHandler; - getLoadingCount$(): Observable; - head: HttpHandler; - intercept(interceptor: HttpInterceptor): () => void; - options: HttpHandler; - patch: HttpHandler; - post: HttpHandler; - put: HttpHandler; -} - -// @public -export type HttpStart = HttpSetup; - -// @public -export interface I18nStart { - Context: ({ children }: { - children: React.ReactNode; - }) => JSX.Element; -} - -// Warning: (ae-missing-release-tag) "IAnonymousPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export interface IAnonymousPaths { - isAnonymous(path: string): boolean; - register(path: string): void; -} - -// @public -export interface IBasePath { - get: () => string; - prepend: (url: string) => string; - remove: (url: string) => string; -} - -// @public -export interface IContextContainer> { - createHandler(pluginOpaqueId: PluginOpaqueId, handler: THandler): (...rest: HandlerParameters) => ShallowPromise>; - registerContext>(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider): this; -} - -// @public -export type IContextProvider, TContextName extends keyof HandlerContextType> = (context: Partial>, ...rest: HandlerParameters) => Promise[TContextName]> | HandlerContextType[TContextName]; - -// @public (undocumented) -export interface IHttpFetchError extends Error { - // (undocumented) - readonly body?: any; - // @deprecated (undocumented) - readonly req: Request; - // (undocumented) - readonly request: Request; - // @deprecated (undocumented) - readonly res?: Response; - // (undocumented) - readonly response?: Response; -} - -// @public -export interface IHttpInterceptController { - halt(): void; - halted: boolean; -} - -// @public (undocumented) -export interface IHttpResponse { - readonly body?: TResponseBody; - readonly request: Readonly; - readonly response?: Readonly; -} - -// @public -export interface IHttpResponseInterceptorOverrides { - readonly body?: TResponseBody; - readonly response?: Readonly; -} - -// @public -export type IToasts = Pick; - -// @public -export interface IUiSettingsClient { - get$: (key: string, defaultOverride?: T) => Observable; - get: (key: string, defaultOverride?: T) => T; - getAll: () => Readonly>; - getSaved$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; - getUpdate$: () => Observable<{ - key: string; - newValue: T; - oldValue: T; - }>; - getUpdateErrors$: () => Observable; - isCustom: (key: string) => boolean; - isDeclared: (key: string) => boolean; - isDefault: (key: string) => boolean; - isOverridden: (key: string) => boolean; - overrideLocalDefault: (key: string, newDefault: any) => void; - remove: (key: string) => Promise; - set: (key: string, value: any) => Promise; -} - -// @public @deprecated -export interface LegacyCoreSetup extends CoreSetup { - // Warning: (ae-forgotten-export) The symbol "InjectedMetadataSetup" needs to be exported by the entry point index.d.ts - // - // @deprecated (undocumented) - injectedMetadata: InjectedMetadataSetup; -} - -// @public @deprecated -export interface LegacyCoreStart extends CoreStart { - // Warning: (ae-forgotten-export) The symbol "InjectedMetadataStart" needs to be exported by the entry point index.d.ts - // - // @deprecated (undocumented) - injectedMetadata: InjectedMetadataStart; -} - -// @public (undocumented) -export interface LegacyNavLink { - // (undocumented) - euiIconType?: string; - // (undocumented) - icon?: string; - // (undocumented) - id: string; - // (undocumented) - order: number; - // (undocumented) - title: string; - // (undocumented) - url: string; -} - -// @public -export type MountPoint = (element: T) => UnmountCallback; - -// @public (undocumented) -export interface NotificationsSetup { - // (undocumented) - toasts: ToastsSetup; -} - -// @public (undocumented) -export interface NotificationsStart { - // (undocumented) - toasts: ToastsStart; -} - -// @public (undocumented) -export interface OverlayBannersStart { - add(mount: MountPoint, priority?: number): string; - // Warning: (ae-forgotten-export) The symbol "OverlayBanner" needs to be exported by the entry point index.d.ts - // - // @internal (undocumented) - get$(): Observable; - // (undocumented) - getComponent(): JSX.Element; - remove(id: string): boolean; - replace(id: string | undefined, mount: MountPoint, priority?: number): string; -} - -// @public -export interface OverlayRef { - close(): Promise; - onClose: Promise; -} - -// @public (undocumented) -export interface OverlayStart { - // (undocumented) - banners: OverlayBannersStart; - // Warning: (ae-forgotten-export) The symbol "OverlayFlyoutStart" needs to be exported by the entry point index.d.ts - // - // (undocumented) - openFlyout: OverlayFlyoutStart['open']; - // Warning: (ae-forgotten-export) The symbol "OverlayModalStart" needs to be exported by the entry point index.d.ts - // - // (undocumented) - openModal: OverlayModalStart['open']; -} - -// @public (undocumented) -export interface PackageInfo { - // (undocumented) - branch: string; - // (undocumented) - buildNum: number; - // (undocumented) - buildSha: string; - // (undocumented) - dist: boolean; - // (undocumented) - version: string; -} - -// @public -export interface Plugin { - // (undocumented) - setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; - // (undocumented) - start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; - // (undocumented) - stop?(): void; -} - -// @public -export type PluginInitializer = (core: PluginInitializerContext) => Plugin; - -// @public -export interface PluginInitializerContext { - // (undocumented) - readonly config: { - get: () => T; - }; - // (undocumented) - readonly env: { - mode: Readonly; - packageInfo: Readonly; - }; - readonly opaqueId: PluginOpaqueId; -} - -// @public (undocumented) -export type PluginOpaqueId = symbol; - -// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export type RecursiveReadonly = T extends (...args: any[]) => any ? T : T extends any[] ? RecursiveReadonlyArray : T extends object ? Readonly<{ - [K in keyof T]: RecursiveReadonly; -}> : T; - -// @public (undocumented) -export interface SavedObject { - attributes: T; - // (undocumented) - error?: { - message: string; - statusCode: number; - }; - id: string; - migrationVersion?: SavedObjectsMigrationVersion; - references: SavedObjectReference[]; - type: string; - updated_at?: string; - version?: string; -} - -// @public -export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttributeSingle[]; - -// @public -export interface SavedObjectAttributes { - // (undocumented) - [key: string]: SavedObjectAttribute; -} - -// @public -export type SavedObjectAttributeSingle = string | number | boolean | null | undefined | SavedObjectAttributes; - -// @public -export interface SavedObjectReference { - // (undocumented) - id: string; - // (undocumented) - name: string; - // (undocumented) - type: string; -} - -// @public (undocumented) -export interface SavedObjectsBaseOptions { - namespace?: string; -} - -// @public (undocumented) -export interface SavedObjectsBatchResponse { - // (undocumented) - savedObjects: Array>; -} - -// @public (undocumented) -export interface SavedObjectsBulkCreateObject extends SavedObjectsCreateOptions { - // (undocumented) - attributes: T; - // (undocumented) - type: string; -} - -// @public (undocumented) -export interface SavedObjectsBulkCreateOptions { - overwrite?: boolean; -} - -// @public (undocumented) -export interface SavedObjectsBulkUpdateObject { - // (undocumented) - attributes: T; - // (undocumented) - id: string; - // (undocumented) - references?: SavedObjectReference[]; - // (undocumented) - type: string; - // (undocumented) - version?: string; -} - -// @public (undocumented) -export interface SavedObjectsBulkUpdateOptions { - // (undocumented) - namespace?: string; -} - -// @public -export class SavedObjectsClient { - // @internal - constructor(http: HttpSetup); - bulkCreate: (objects?: SavedObjectsBulkCreateObject[], options?: SavedObjectsBulkCreateOptions) => Promise>; - bulkGet: (objects?: { - id: string; - type: string; - }[]) => Promise>; - bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; - create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; - delete: (type: string, id: string) => Promise<{}>; - find: (options: Pick) => Promise>; - get: (type: string, id: string) => Promise>; - update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>; -} - -// @public -export type SavedObjectsClientContract = PublicMethodsOf; - -// @public (undocumented) -export interface SavedObjectsCreateOptions { - id?: string; - migrationVersion?: SavedObjectsMigrationVersion; - overwrite?: boolean; - // (undocumented) - references?: SavedObjectReference[]; -} - -// @public (undocumented) -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { - // (undocumented) - defaultSearchOperator?: 'AND' | 'OR'; - fields?: string[]; - // (undocumented) - filter?: string; - // (undocumented) - hasReference?: { - type: string; - id: string; - }; - // (undocumented) - page?: number; - // (undocumented) - perPage?: number; - search?: string; - searchFields?: string[]; - // (undocumented) - sortField?: string; - // (undocumented) - sortOrder?: string; - // (undocumented) - type: string | string[]; -} - -// @public -export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { - // (undocumented) - page: number; - // (undocumented) - perPage: number; - // (undocumented) - total: number; -} - -// @public -export interface SavedObjectsImportConflictError { - // (undocumented) - type: 'conflict'; -} - -// @public -export interface SavedObjectsImportError { - // (undocumented) - error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; - // (undocumented) - id: string; - // (undocumented) - title?: string; - // (undocumented) - type: string; -} - -// @public -export interface SavedObjectsImportMissingReferencesError { - // (undocumented) - blocking: Array<{ - type: string; - id: string; - }>; - // (undocumented) - references: Array<{ - type: string; - id: string; - }>; - // (undocumented) - type: 'missing_references'; -} - -// @public -export interface SavedObjectsImportResponse { - // (undocumented) - errors?: SavedObjectsImportError[]; - // (undocumented) - success: boolean; - // (undocumented) - successCount: number; -} - -// @public -export interface SavedObjectsImportRetry { - // (undocumented) - id: string; - // (undocumented) - overwrite: boolean; - // (undocumented) - replaceReferences: Array<{ - type: string; - from: string; - to: string; - }>; - // (undocumented) - type: string; -} - -// @public -export interface SavedObjectsImportUnknownError { - // (undocumented) - message: string; - // (undocumented) - statusCode: number; - // (undocumented) - type: 'unknown'; -} - -// @public -export interface SavedObjectsImportUnsupportedTypeError { - // (undocumented) - type: 'unsupported_type'; -} - -// @public -export interface SavedObjectsMigrationVersion { - // (undocumented) - [pluginName: string]: string; -} - -// @public (undocumented) -export interface SavedObjectsStart { - // (undocumented) - client: SavedObjectsClientContract; -} - -// @public (undocumented) -export interface SavedObjectsUpdateOptions { - migrationVersion?: SavedObjectsMigrationVersion; - // (undocumented) - references?: SavedObjectReference[]; - // (undocumented) - version?: string; -} - -// @public -export class SimpleSavedObject { - constructor(client: SavedObjectsClient, { id, type, version, attributes, error, references, migrationVersion }: SavedObject); - // (undocumented) - attributes: T; - // (undocumented) - delete(): Promise<{}>; - // (undocumented) - error: SavedObject['error']; - // (undocumented) - get(key: string): any; - // (undocumented) - has(key: string): boolean; - // (undocumented) - id: SavedObject['id']; - // (undocumented) - migrationVersion: SavedObject['migrationVersion']; - // (undocumented) - references: SavedObject['references']; - // (undocumented) - save(): Promise>; - // (undocumented) - set(key: string, value: any): T; - // (undocumented) - type: SavedObject['type']; - // (undocumented) - _version?: SavedObject['version']; -} - -// Warning: (ae-missing-release-tag) "Toast" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type Toast = ToastInputFields & { - id: string; -}; - -// @public -export type ToastInput = string | ToastInputFields; - -// @public -export type ToastInputFields = Pick> & { - title?: string | MountPoint; - text?: string | MountPoint; -}; - -// @public -export class ToastsApi implements IToasts { - constructor(deps: { - uiSettings: IUiSettingsClient; - }); - add(toastOrTitle: ToastInput): Toast; - addDanger(toastOrTitle: ToastInput): Toast; - addError(error: Error, options: ErrorToastOptions): Toast; - addSuccess(toastOrTitle: ToastInput): Toast; - addWarning(toastOrTitle: ToastInput): Toast; - get$(): Rx.Observable; - remove(toastOrId: Toast | string): void; - // @internal (undocumented) - start({ overlays, i18n }: { - overlays: OverlayStart; - i18n: I18nStart; - }): void; - } - -// @public (undocumented) -export type ToastsSetup = IToasts; - -// @public (undocumented) -export type ToastsStart = IToasts; - -// @public (undocumented) -export interface UiSettingsState { - // (undocumented) - [key: string]: UiSettingsParams_2 & UserProvidedValues_2; -} - -// @public -export type UnmountCallback = () => void; - - -``` +## API Report File for "kibana" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Breadcrumb } from '@elastic/eui'; +import { EuiButtonEmptyProps } from '@elastic/eui'; +import { EuiGlobalToastListToast } from '@elastic/eui'; +import { ExclusiveUnion } from '@elastic/eui'; +import { IconType } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import React from 'react'; +import * as Rx from 'rxjs'; +import { ShallowPromise } from '@kbn/utility-types'; +import { UiSettingsParams as UiSettingsParams_2 } from 'src/core/server/types'; +import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types'; + +// @public +export interface App extends AppBase { + appRoute?: string; + chromeless?: boolean; + mount: AppMount | AppMountDeprecated; +} + +// @public (undocumented) +export interface AppBase { + capabilities?: Partial; + chromeless?: boolean; + euiIconType?: string; + icon?: string; + id: string; + // @internal + legacy?: boolean; + navLinkStatus?: AppNavLinkStatus; + order?: number; + status?: AppStatus; + title: string; + tooltip?: string; + updater$?: Observable; +} + +// @public +export type AppLeaveAction = AppLeaveDefaultAction | AppLeaveConfirmAction; + +// @public +export enum AppLeaveActionType { + // (undocumented) + confirm = "confirm", + // (undocumented) + default = "default" +} + +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AppLeaveActionFactory" +// +// @public +export interface AppLeaveConfirmAction { + // (undocumented) + text: string; + // (undocumented) + title?: string; + // (undocumented) + type: AppLeaveActionType.confirm; +} + +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AppLeaveActionFactory" +// +// @public +export interface AppLeaveDefaultAction { + // (undocumented) + type: AppLeaveActionType.default; +} + +// Warning: (ae-forgotten-export) The symbol "AppLeaveActionFactory" needs to be exported by the entry point index.d.ts +// +// @public +export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; + +// @public (undocumented) +export interface ApplicationSetup { + register(app: App): void; + registerAppUpdater(appUpdater$: Observable): void; + // @deprecated + registerMountContext(contextName: T, provider: IContextProvider): void; +} + +// @public (undocumented) +export interface ApplicationStart { + capabilities: RecursiveReadonly; + getUrlForApp(appId: string, options?: { + path?: string; + }): string; + navigateToApp(appId: string, options?: { + path?: string; + state?: any; + }): Promise; + // @deprecated + registerMountContext(contextName: T, provider: IContextProvider): void; +} + +// @public +export type AppMount = (params: AppMountParameters) => AppUnmount | Promise; + +// @public @deprecated +export interface AppMountContext { + core: { + application: Pick; + chrome: ChromeStart; + docLinks: DocLinksStart; + http: HttpStart; + i18n: I18nStart; + notifications: NotificationsStart; + overlays: OverlayStart; + savedObjects: SavedObjectsStart; + uiSettings: IUiSettingsClient; + injectedMetadata: { + getInjectedVar: (name: string, defaultValue?: any) => unknown; + }; + }; +} + +// @public @deprecated +export type AppMountDeprecated = (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; + +// @public (undocumented) +export interface AppMountParameters { + appBasePath: string; + element: HTMLElement; + onAppLeave: (handler: AppLeaveHandler) => void; +} + +// @public +export enum AppNavLinkStatus { + default = 0, + disabled = 2, + hidden = 3, + visible = 1 +} + +// @public +export enum AppStatus { + accessible = 0, + inaccessible = 1 +} + +// @public +export type AppUnmount = () => void; + +// @public +export type AppUpdatableFields = Pick; + +// @public +export type AppUpdater = (app: AppBase) => Partial | undefined; + +// @public +export interface Capabilities { + [key: string]: Record>; + catalogue: Record; + management: { + [sectionId: string]: Record; + }; + navLinks: Record; +} + +// @public (undocumented) +export interface ChromeBadge { + // (undocumented) + iconType?: IconType; + // (undocumented) + text: string; + // (undocumented) + tooltip: string; +} + +// @public (undocumented) +export interface ChromeBrand { + // (undocumented) + logo?: string; + // (undocumented) + smallLogo?: string; +} + +// @public (undocumented) +export type ChromeBreadcrumb = Breadcrumb; + +// @public +export interface ChromeDocTitle { + // @internal (undocumented) + __legacy: { + setBaseTitle(baseTitle: string): void; + }; + change(newTitle: string | string[]): void; + reset(): void; +} + +// @public (undocumented) +export interface ChromeHelpExtension { + appName: string; + content?: (element: HTMLDivElement) => () => void; + links?: ChromeHelpExtensionMenuLink[]; +} + +// @public (undocumented) +export type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { + linkType: 'custom'; + content: React.ReactNode; +}; + +// @public (undocumented) +export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { + linkType: 'discuss'; + href: string; +}; + +// @public (undocumented) +export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { + linkType: 'documentation'; + href: string; +}; + +// @public (undocumented) +export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { + linkType: 'github'; + labels: string[]; + title?: string; +}; + +// @public (undocumented) +export type ChromeHelpExtensionMenuLink = ExclusiveUnion>>; + +// @public (undocumented) +export interface ChromeNavControl { + // (undocumented) + mount: MountPoint; + // (undocumented) + order?: number; +} + +// @public +export interface ChromeNavControls { + // @internal (undocumented) + getLeft$(): Observable; + // @internal (undocumented) + getRight$(): Observable; + registerLeft(navControl: ChromeNavControl): void; + registerRight(navControl: ChromeNavControl): void; +} + +// @public (undocumented) +export interface ChromeNavLink { + // @deprecated + readonly active?: boolean; + readonly baseUrl: string; + // @deprecated + readonly disabled?: boolean; + readonly euiIconType?: string; + readonly hidden?: boolean; + readonly icon?: string; + readonly id: string; + // @internal + readonly legacy: boolean; + // @deprecated + readonly linkToLastSubUrl?: boolean; + readonly order?: number; + // @deprecated + readonly subUrlBase?: string; + readonly title: string; + readonly tooltip?: string; + // @deprecated + readonly url?: string; +} + +// @public +export interface ChromeNavLinks { + enableForcedAppSwitcherNavigation(): void; + get(id: string): ChromeNavLink | undefined; + getAll(): Array>; + getForceAppSwitcherNavigation$(): Observable; + getNavLinks$(): Observable>>; + has(id: string): boolean; + showOnly(id: string): void; + update(id: string, values: ChromeNavLinkUpdateableFields): ChromeNavLink | undefined; +} + +// @public (undocumented) +export type ChromeNavLinkUpdateableFields = Partial>; + +// @public +export interface ChromeRecentlyAccessed { + // Warning: (ae-unresolved-link) The @link reference could not be resolved: No member was found with name "basePath" + add(link: string, label: string, id: string): void; + get$(): Observable; + get(): ChromeRecentlyAccessedHistoryItem[]; +} + +// @public (undocumented) +export interface ChromeRecentlyAccessedHistoryItem { + // (undocumented) + id: string; + // (undocumented) + label: string; + // (undocumented) + link: string; +} + +// @public +export interface ChromeStart { + addApplicationClass(className: string): void; + docTitle: ChromeDocTitle; + getApplicationClasses$(): Observable; + getBadge$(): Observable; + getBrand$(): Observable; + getBreadcrumbs$(): Observable; + getHelpExtension$(): Observable; + getIsCollapsed$(): Observable; + getIsVisible$(): Observable; + navControls: ChromeNavControls; + navLinks: ChromeNavLinks; + recentlyAccessed: ChromeRecentlyAccessed; + removeApplicationClass(className: string): void; + setAppTitle(appTitle: string): void; + setBadge(badge?: ChromeBadge): void; + setBrand(brand: ChromeBrand): void; + setBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]): void; + setHelpExtension(helpExtension?: ChromeHelpExtension): void; + setHelpSupportUrl(url: string): void; + setIsCollapsed(isCollapsed: boolean): void; + setIsVisible(isVisible: boolean): void; +} + +// @public +export interface ContextSetup { + createContextContainer>(): IContextContainer; +} + +// @internal (undocumented) +export interface CoreContext { + // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + coreId: CoreId; + // (undocumented) + env: { + mode: Readonly; + packageInfo: Readonly; + }; +} + +// @public +export interface CoreSetup { + // (undocumented) + application: ApplicationSetup; + // @deprecated (undocumented) + context: ContextSetup; + // (undocumented) + fatalErrors: FatalErrorsSetup; + getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + http: HttpSetup; + // @deprecated + injectedMetadata: { + getInjectedVar: (name: string, defaultValue?: any) => unknown; + }; + // (undocumented) + notifications: NotificationsSetup; + // (undocumented) + uiSettings: IUiSettingsClient; +} + +// @public +export interface CoreStart { + // (undocumented) + application: ApplicationStart; + // (undocumented) + chrome: ChromeStart; + // (undocumented) + docLinks: DocLinksStart; + // (undocumented) + http: HttpStart; + // (undocumented) + i18n: I18nStart; + // @deprecated + injectedMetadata: { + getInjectedVar: (name: string, defaultValue?: any) => unknown; + }; + // (undocumented) + notifications: NotificationsStart; + // (undocumented) + overlays: OverlayStart; + // (undocumented) + savedObjects: SavedObjectsStart; + // (undocumented) + uiSettings: IUiSettingsClient; +} + +// @internal +export class CoreSystem { + // Warning: (ae-forgotten-export) The symbol "Params" needs to be exported by the entry point index.d.ts + constructor(params: Params); + // (undocumented) + setup(): Promise<{ + fatalErrors: FatalErrorsSetup; + } | undefined>; + // (undocumented) + start(): Promise; + // (undocumented) + stop(): void; + } + +// @public (undocumented) +export interface DocLinksStart { + // (undocumented) + readonly DOC_LINK_VERSION: string; + // (undocumented) + readonly ELASTIC_WEBSITE_URL: string; + // (undocumented) + readonly links: { + readonly filebeat: { + readonly base: string; + readonly installation: string; + readonly configuration: string; + readonly elasticsearchOutput: string; + readonly startup: string; + readonly exportedFields: string; + }; + readonly auditbeat: { + readonly base: string; + }; + readonly metricbeat: { + readonly base: string; + }; + readonly heartbeat: { + readonly base: string; + }; + readonly logstash: { + readonly base: string; + }; + readonly functionbeat: { + readonly base: string; + }; + readonly winlogbeat: { + readonly base: string; + }; + readonly aggs: { + readonly date_histogram: string; + readonly date_range: string; + readonly filter: string; + readonly filters: string; + readonly geohash_grid: string; + readonly histogram: string; + readonly ip_range: string; + readonly range: string; + readonly significant_terms: string; + readonly terms: string; + readonly avg: string; + readonly avg_bucket: string; + readonly max_bucket: string; + readonly min_bucket: string; + readonly sum_bucket: string; + readonly cardinality: string; + readonly count: string; + readonly cumulative_sum: string; + readonly derivative: string; + readonly geo_bounds: string; + readonly geo_centroid: string; + readonly max: string; + readonly median: string; + readonly min: string; + readonly moving_avg: string; + readonly percentile_ranks: string; + readonly serial_diff: string; + readonly std_dev: string; + readonly sum: string; + readonly top_hits: string; + }; + readonly scriptedFields: { + readonly scriptFields: string; + readonly scriptAggs: string; + readonly painless: string; + readonly painlessApi: string; + readonly painlessSyntax: string; + readonly luceneExpressions: string; + }; + readonly indexPatterns: { + readonly loadingData: string; + readonly introduction: string; + }; + readonly kibana: string; + readonly siem: string; + readonly query: { + readonly luceneQuerySyntax: string; + readonly queryDsl: string; + readonly kueryQuerySyntax: string; + }; + readonly date: { + readonly dateMath: string; + }; + }; +} + +// @public (undocumented) +export interface EnvironmentMode { + // (undocumented) + dev: boolean; + // (undocumented) + name: 'development' | 'production'; + // (undocumented) + prod: boolean; +} + +// @public +export interface ErrorToastOptions { + title: string; + toastMessage?: string; +} + +// @public +export interface FatalErrorInfo { + // (undocumented) + message: string; + // (undocumented) + stack: string | undefined; +} + +// @public +export interface FatalErrorsSetup { + add: (error: string | Error, source?: string) => never; + get$: () => Rx.Observable; +} + +// @public +export type HandlerContextType> = T extends HandlerFunction ? U : never; + +// @public +export type HandlerFunction = (context: T, ...args: any[]) => any; + +// @public +export type HandlerParameters> = T extends (context: any, ...args: infer U) => any ? U : never; + +// @public (undocumented) +export interface HttpErrorRequest { + // (undocumented) + error: Error; + // (undocumented) + request: Request; +} + +// @public (undocumented) +export interface HttpErrorResponse extends IHttpResponse { + // (undocumented) + error: Error | IHttpFetchError; +} + +// @public +export interface HttpFetchOptions extends HttpRequestInit { + asResponse?: boolean; + headers?: HttpHeadersInit; + prependBasePath?: boolean; + query?: HttpFetchQuery; +} + +// @public (undocumented) +export interface HttpFetchQuery { + // (undocumented) + [key: string]: string | number | boolean | undefined; +} + +// @public +export interface HttpHandler { + // (undocumented) + (path: string, options: HttpFetchOptions & { + asResponse: true; + }): Promise>; + // (undocumented) + (path: string, options?: HttpFetchOptions): Promise; +} + +// @public (undocumented) +export interface HttpHeadersInit { + // (undocumented) + [name: string]: any; +} + +// @public +export interface HttpInterceptor { + request?(request: Request, controller: IHttpInterceptController): Promise | Request | void; + requestError?(httpErrorRequest: HttpErrorRequest, controller: IHttpInterceptController): Promise | Request | void; + response?(httpResponse: IHttpResponse, controller: IHttpInterceptController): Promise | IHttpResponseInterceptorOverrides | void; + responseError?(httpErrorResponse: HttpErrorResponse, controller: IHttpInterceptController): Promise | IHttpResponseInterceptorOverrides | void; +} + +// @public +export interface HttpRequestInit { + body?: BodyInit | null; + cache?: RequestCache; + credentials?: RequestCredentials; + // (undocumented) + headers?: HttpHeadersInit; + integrity?: string; + keepalive?: boolean; + method?: string; + mode?: RequestMode; + redirect?: RequestRedirect; + referrer?: string; + referrerPolicy?: ReferrerPolicy; + signal?: AbortSignal | null; + window?: null; +} + +// @public (undocumented) +export interface HttpSetup { + addLoadingCountSource(countSource$: Observable): void; + anonymousPaths: IAnonymousPaths; + basePath: IBasePath; + delete: HttpHandler; + fetch: HttpHandler; + get: HttpHandler; + getLoadingCount$(): Observable; + head: HttpHandler; + intercept(interceptor: HttpInterceptor): () => void; + options: HttpHandler; + patch: HttpHandler; + post: HttpHandler; + put: HttpHandler; +} + +// @public +export type HttpStart = HttpSetup; + +// @public +export interface I18nStart { + Context: ({ children }: { + children: React.ReactNode; + }) => JSX.Element; +} + +// Warning: (ae-missing-release-tag) "IAnonymousPaths" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface IAnonymousPaths { + isAnonymous(path: string): boolean; + register(path: string): void; +} + +// @public +export interface IBasePath { + get: () => string; + prepend: (url: string) => string; + remove: (url: string) => string; +} + +// @public +export interface IContextContainer> { + createHandler(pluginOpaqueId: PluginOpaqueId, handler: THandler): (...rest: HandlerParameters) => ShallowPromise>; + registerContext>(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider): this; +} + +// @public +export type IContextProvider, TContextName extends keyof HandlerContextType> = (context: Partial>, ...rest: HandlerParameters) => Promise[TContextName]> | HandlerContextType[TContextName]; + +// @public (undocumented) +export interface IHttpFetchError extends Error { + // (undocumented) + readonly body?: any; + // @deprecated (undocumented) + readonly req: Request; + // (undocumented) + readonly request: Request; + // @deprecated (undocumented) + readonly res?: Response; + // (undocumented) + readonly response?: Response; +} + +// @public +export interface IHttpInterceptController { + halt(): void; + halted: boolean; +} + +// @public (undocumented) +export interface IHttpResponse { + readonly body?: TResponseBody; + readonly request: Readonly; + readonly response?: Readonly; +} + +// @public +export interface IHttpResponseInterceptorOverrides { + readonly body?: TResponseBody; + readonly response?: Readonly; +} + +// @public +export type IToasts = Pick; + +// @public +export interface IUiSettingsClient { + get$: (key: string, defaultOverride?: T) => Observable; + get: (key: string, defaultOverride?: T) => T; + getAll: () => Readonly>; + getSaved$: () => Observable<{ + key: string; + newValue: T; + oldValue: T; + }>; + getUpdate$: () => Observable<{ + key: string; + newValue: T; + oldValue: T; + }>; + getUpdateErrors$: () => Observable; + isCustom: (key: string) => boolean; + isDeclared: (key: string) => boolean; + isDefault: (key: string) => boolean; + isOverridden: (key: string) => boolean; + overrideLocalDefault: (key: string, newDefault: any) => void; + remove: (key: string) => Promise; + set: (key: string, value: any) => Promise; +} + +// @public @deprecated +export interface LegacyCoreSetup extends CoreSetup { + // Warning: (ae-forgotten-export) The symbol "InjectedMetadataSetup" needs to be exported by the entry point index.d.ts + // + // @deprecated (undocumented) + injectedMetadata: InjectedMetadataSetup; +} + +// @public @deprecated +export interface LegacyCoreStart extends CoreStart { + // Warning: (ae-forgotten-export) The symbol "InjectedMetadataStart" needs to be exported by the entry point index.d.ts + // + // @deprecated (undocumented) + injectedMetadata: InjectedMetadataStart; +} + +// @public (undocumented) +export interface LegacyNavLink { + // (undocumented) + euiIconType?: string; + // (undocumented) + icon?: string; + // (undocumented) + id: string; + // (undocumented) + order: number; + // (undocumented) + title: string; + // (undocumented) + url: string; +} + +// @public +export type MountPoint = (element: T) => UnmountCallback; + +// @public (undocumented) +export interface NotificationsSetup { + // (undocumented) + toasts: ToastsSetup; +} + +// @public (undocumented) +export interface NotificationsStart { + // (undocumented) + toasts: ToastsStart; +} + +// @public (undocumented) +export interface OverlayBannersStart { + add(mount: MountPoint, priority?: number): string; + // Warning: (ae-forgotten-export) The symbol "OverlayBanner" needs to be exported by the entry point index.d.ts + // + // @internal (undocumented) + get$(): Observable; + // (undocumented) + getComponent(): JSX.Element; + remove(id: string): boolean; + replace(id: string | undefined, mount: MountPoint, priority?: number): string; +} + +// @public +export interface OverlayRef { + close(): Promise; + onClose: Promise; +} + +// @public (undocumented) +export interface OverlayStart { + // (undocumented) + banners: OverlayBannersStart; + // (undocumented) + openConfirm: OverlayModalStart['openConfirm']; + // Warning: (ae-forgotten-export) The symbol "OverlayFlyoutStart" needs to be exported by the entry point index.d.ts + // + // (undocumented) + openFlyout: OverlayFlyoutStart['open']; + // Warning: (ae-forgotten-export) The symbol "OverlayModalStart" needs to be exported by the entry point index.d.ts + // + // (undocumented) + openModal: OverlayModalStart['open']; +} + +// @public (undocumented) +export interface PackageInfo { + // (undocumented) + branch: string; + // (undocumented) + buildNum: number; + // (undocumented) + buildSha: string; + // (undocumented) + dist: boolean; + // (undocumented) + version: string; +} + +// @public +export interface Plugin { + // (undocumented) + setup(core: CoreSetup, plugins: TPluginsSetup): TSetup | Promise; + // (undocumented) + start(core: CoreStart, plugins: TPluginsStart): TStart | Promise; + // (undocumented) + stop?(): void; +} + +// @public +export type PluginInitializer = (core: PluginInitializerContext) => Plugin; + +// @public +export interface PluginInitializerContext { + // (undocumented) + readonly config: { + get: () => T; + }; + // (undocumented) + readonly env: { + mode: Readonly; + packageInfo: Readonly; + }; + readonly opaqueId: PluginOpaqueId; +} + +// @public (undocumented) +export type PluginOpaqueId = symbol; + +// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type RecursiveReadonly = T extends (...args: any[]) => any ? T : T extends any[] ? RecursiveReadonlyArray : T extends object ? Readonly<{ + [K in keyof T]: RecursiveReadonly; +}> : T; + +// @public (undocumented) +export interface SavedObject { + attributes: T; + // (undocumented) + error?: { + message: string; + statusCode: number; + }; + id: string; + migrationVersion?: SavedObjectsMigrationVersion; + references: SavedObjectReference[]; + type: string; + updated_at?: string; + version?: string; +} + +// @public +export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttributeSingle[]; + +// @public +export interface SavedObjectAttributes { + // (undocumented) + [key: string]: SavedObjectAttribute; +} + +// @public +export type SavedObjectAttributeSingle = string | number | boolean | null | undefined | SavedObjectAttributes; + +// @public +export interface SavedObjectReference { + // (undocumented) + id: string; + // (undocumented) + name: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBaseOptions { + namespace?: string; +} + +// @public (undocumented) +export interface SavedObjectsBatchResponse { + // (undocumented) + savedObjects: Array>; +} + +// @public (undocumented) +export interface SavedObjectsBulkCreateObject extends SavedObjectsCreateOptions { + // (undocumented) + attributes: T; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkCreateOptions { + overwrite?: boolean; +} + +// @public (undocumented) +export interface SavedObjectsBulkUpdateObject { + // (undocumented) + attributes: T; + // (undocumented) + id: string; + // (undocumented) + references?: SavedObjectReference[]; + // (undocumented) + type: string; + // (undocumented) + version?: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkUpdateOptions { + // (undocumented) + namespace?: string; +} + +// @public +export class SavedObjectsClient { + // @internal + constructor(http: HttpSetup); + bulkCreate: (objects?: SavedObjectsBulkCreateObject[], options?: SavedObjectsBulkCreateOptions) => Promise>; + bulkGet: (objects?: { + id: string; + type: string; + }[]) => Promise>; + bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; + create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; + delete: (type: string, id: string) => Promise<{}>; + find: (options: Pick) => Promise>; + get: (type: string, id: string) => Promise>; + update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>; +} + +// @public +export type SavedObjectsClientContract = PublicMethodsOf; + +// @public (undocumented) +export interface SavedObjectsCreateOptions { + id?: string; + migrationVersion?: SavedObjectsMigrationVersion; + overwrite?: boolean; + // (undocumented) + references?: SavedObjectReference[]; +} + +// @public (undocumented) +export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { + // (undocumented) + defaultSearchOperator?: 'AND' | 'OR'; + fields?: string[]; + // (undocumented) + filter?: string; + // (undocumented) + hasReference?: { + type: string; + id: string; + }; + // (undocumented) + page?: number; + // (undocumented) + perPage?: number; + search?: string; + searchFields?: string[]; + // (undocumented) + sortField?: string; + // (undocumented) + sortOrder?: string; + // (undocumented) + type: string | string[]; +} + +// @public +export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse { + // (undocumented) + page: number; + // (undocumented) + perPage: number; + // (undocumented) + total: number; +} + +// @public +export interface SavedObjectsImportConflictError { + // (undocumented) + type: 'conflict'; +} + +// @public +export interface SavedObjectsImportError { + // (undocumented) + error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + // (undocumented) + id: string; + // (undocumented) + title?: string; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsImportMissingReferencesError { + // (undocumented) + blocking: Array<{ + type: string; + id: string; + }>; + // (undocumented) + references: Array<{ + type: string; + id: string; + }>; + // (undocumented) + type: 'missing_references'; +} + +// @public +export interface SavedObjectsImportResponse { + // (undocumented) + errors?: SavedObjectsImportError[]; + // (undocumented) + success: boolean; + // (undocumented) + successCount: number; +} + +// @public +export interface SavedObjectsImportRetry { + // (undocumented) + id: string; + // (undocumented) + overwrite: boolean; + // (undocumented) + replaceReferences: Array<{ + type: string; + from: string; + to: string; + }>; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsImportUnknownError { + // (undocumented) + message: string; + // (undocumented) + statusCode: number; + // (undocumented) + type: 'unknown'; +} + +// @public +export interface SavedObjectsImportUnsupportedTypeError { + // (undocumented) + type: 'unsupported_type'; +} + +// @public +export interface SavedObjectsMigrationVersion { + // (undocumented) + [pluginName: string]: string; +} + +// @public (undocumented) +export interface SavedObjectsStart { + // (undocumented) + client: SavedObjectsClientContract; +} + +// @public (undocumented) +export interface SavedObjectsUpdateOptions { + migrationVersion?: SavedObjectsMigrationVersion; + // (undocumented) + references?: SavedObjectReference[]; + // (undocumented) + version?: string; +} + +// @public +export class SimpleSavedObject { + constructor(client: SavedObjectsClient, { id, type, version, attributes, error, references, migrationVersion }: SavedObject); + // (undocumented) + attributes: T; + // (undocumented) + delete(): Promise<{}>; + // (undocumented) + error: SavedObject['error']; + // (undocumented) + get(key: string): any; + // (undocumented) + has(key: string): boolean; + // (undocumented) + id: SavedObject['id']; + // (undocumented) + migrationVersion: SavedObject['migrationVersion']; + // (undocumented) + references: SavedObject['references']; + // (undocumented) + save(): Promise>; + // (undocumented) + set(key: string, value: any): T; + // (undocumented) + type: SavedObject['type']; + // (undocumented) + _version?: SavedObject['version']; +} + +// Warning: (ae-missing-release-tag) "Toast" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type Toast = ToastInputFields & { + id: string; +}; + +// @public +export type ToastInput = string | ToastInputFields; + +// @public +export type ToastInputFields = Pick> & { + title?: string | MountPoint; + text?: string | MountPoint; +}; + +// @public +export class ToastsApi implements IToasts { + constructor(deps: { + uiSettings: IUiSettingsClient; + }); + add(toastOrTitle: ToastInput): Toast; + addDanger(toastOrTitle: ToastInput): Toast; + addError(error: Error, options: ErrorToastOptions): Toast; + addSuccess(toastOrTitle: ToastInput): Toast; + addWarning(toastOrTitle: ToastInput): Toast; + get$(): Rx.Observable; + remove(toastOrId: Toast | string): void; + // @internal (undocumented) + start({ overlays, i18n }: { + overlays: OverlayStart; + i18n: I18nStart; + }): void; + } + +// @public (undocumented) +export type ToastsSetup = IToasts; + +// @public (undocumented) +export type ToastsStart = IToasts; + +// @public (undocumented) +export interface UiSettingsState { + // (undocumented) + [key: string]: UiSettingsParams_2 & UserProvidedValues_2; +} + +// @public +export type UnmountCallback = () => void; + + +``` diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/elasticsearch_client_config.test.ts index 64fb41cb3e4e5a..20c10459e0e8a4 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_client_config.test.ts @@ -17,8 +17,6 @@ * under the License. */ -import { mockReadFileSync } from './elasticsearch_client_config.test.mocks'; - import { duration } from 'moment'; import { loggingServiceMock } from '../logging/logging_service.mock'; import { @@ -66,8 +64,6 @@ Object { }); test('parses fully specified config', () => { - mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); - const elasticsearchConfig: ElasticsearchClientConfig = { apiVersion: 'v7.0.0', customHeaders: { xsrf: 'something' }, @@ -87,9 +83,9 @@ test('parses fully specified config', () => { sniffInterval: 11223344, ssl: { verificationMode: 'certificate', - certificateAuthorities: ['ca-path-1', 'ca-path-2'], - certificate: 'certificate-path', - key: 'key-path', + certificateAuthorities: ['content-of-ca-path-1', 'content-of-ca-path-2'], + certificate: 'content-of-certificate-path', + key: 'content-of-key-path', keyPassphrase: 'key-pass', alwaysPresentCertificate: true, }, @@ -497,6 +493,7 @@ Object { "sniffOnConnectionFault": true, "sniffOnStart": true, "ssl": Object { + "ca": undefined, "rejectUnauthorized": false, }, } @@ -541,6 +538,7 @@ Object { "sniffOnConnectionFault": true, "sniffOnStart": true, "ssl": Object { + "ca": undefined, "checkServerIdentity": [Function], "rejectUnauthorized": true, }, @@ -581,6 +579,7 @@ Object { "sniffOnConnectionFault": true, "sniffOnStart": true, "ssl": Object { + "ca": undefined, "rejectUnauthorized": true, }, } @@ -606,8 +605,6 @@ Object { }); test('#ignoreCertAndKey = true', () => { - mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); - expect( parseElasticsearchClientConfig( { @@ -620,9 +617,9 @@ Object { requestHeadersWhitelist: [], ssl: { verificationMode: 'certificate', - certificateAuthorities: ['ca-path'], - certificate: 'certificate-path', - key: 'key-path', + certificateAuthorities: ['content-of-ca-path'], + certificate: 'content-of-certificate-path', + key: 'content-of-key-path', keyPassphrase: 'key-pass', alwaysPresentCertificate: true, }, diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.ts b/src/core/server/elasticsearch/elasticsearch_client_config.ts index dcc09f711abbeb..287d835c40351b 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_client_config.ts @@ -18,7 +18,6 @@ */ import { ConfigOptions } from 'elasticsearch'; -import { readFileSync } from 'fs'; import { cloneDeep } from 'lodash'; import { Duration } from 'moment'; import { checkServerIdentity } from 'tls'; @@ -165,18 +164,12 @@ export function parseElasticsearchClientConfig( throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); } - const readFile = (file: string) => readFileSync(file, 'utf8'); - if ( - config.ssl.certificateAuthorities !== undefined && - config.ssl.certificateAuthorities.length > 0 - ) { - esClientConfig.ssl.ca = config.ssl.certificateAuthorities.map(readFile); - } + esClientConfig.ssl.ca = config.ssl.certificateAuthorities; // Add client certificate and key if required by elasticsearch if (!ignoreCertAndKey && config.ssl.certificate && config.ssl.key) { - esClientConfig.ssl.cert = readFile(config.ssl.certificate); - esClientConfig.ssl.key = readFile(config.ssl.key); + esClientConfig.ssl.cert = config.ssl.certificate; + esClientConfig.ssl.key = config.ssl.key; esClientConfig.ssl.passphrase = config.ssl.keyPassphrase; } diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts b/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts new file mode 100644 index 00000000000000..d908fdbfd2e807 --- /dev/null +++ b/src/core/server/elasticsearch/elasticsearch_config.test.mocks.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const mockReadFileSync = jest.fn(); +jest.mock('fs', () => ({ readFileSync: mockReadFileSync })); + +export const mockReadPkcs12Keystore = jest.fn(); +export const mockReadPkcs12Truststore = jest.fn(); +jest.mock('../../utils', () => ({ + readPkcs12Keystore: mockReadPkcs12Keystore, + readPkcs12Truststore: mockReadPkcs12Truststore, +})); diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index 1d919639abe5f1..1b4fc5eafec763 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -17,7 +17,35 @@ * under the License. */ +import { + mockReadFileSync, + mockReadPkcs12Keystore, + mockReadPkcs12Truststore, +} from './elasticsearch_config.test.mocks'; + import { ElasticsearchConfig, config } from './elasticsearch_config'; +import { applyDeprecations, configDeprecationFactory } from '../config/deprecation'; + +const CONFIG_PATH = 'elasticsearch'; + +const applyElasticsearchDeprecations = (settings: Record = {}) => { + const deprecations = config.deprecations!(configDeprecationFactory); + const deprecationMessages: string[] = []; + const _config: any = {}; + _config[CONFIG_PATH] = settings; + const migrated = applyDeprecations( + _config, + deprecations.map(deprecation => ({ + deprecation, + path: CONFIG_PATH, + })), + msg => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; +}; test('set correct defaults', () => { const configValue = new ElasticsearchConfig(config.schema.validate({})); @@ -43,7 +71,10 @@ test('set correct defaults', () => { "sniffOnStart": false, "ssl": Object { "alwaysPresentCertificate": false, + "certificate": undefined, "certificateAuthorities": undefined, + "key": undefined, + "keyPassphrase": undefined, "verificationMode": "full", }, "username": undefined, @@ -89,23 +120,238 @@ test('#requestHeadersWhitelist accepts both string and array of strings', () => expect(configValue.requestHeadersWhitelist).toEqual(['token', 'X-Forwarded-Proto']); }); -test('#ssl.certificateAuthorities accepts both string and array of strings', () => { - let configValue = new ElasticsearchConfig( - config.schema.validate({ ssl: { certificateAuthorities: 'some-path' } }) - ); - expect(configValue.ssl.certificateAuthorities).toEqual(['some-path']); +describe('reads files', () => { + beforeEach(() => { + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + mockReadPkcs12Keystore.mockReset(); + mockReadPkcs12Keystore.mockImplementation((path: string) => ({ + key: `content-of-${path}.key`, + cert: `content-of-${path}.cert`, + ca: [`content-of-${path}.ca`], + })); + mockReadPkcs12Truststore.mockReset(); + mockReadPkcs12Truststore.mockImplementation((path: string) => [`content-of-${path}`]); + }); - configValue = new ElasticsearchConfig( - config.schema.validate({ ssl: { certificateAuthorities: ['some-path'] } }) - ); - expect(configValue.ssl.certificateAuthorities).toEqual(['some-path']); + it('reads certificate authorities when ssl.keystore.path is specified', () => { + const configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) + ); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path.ca']); + }); - configValue = new ElasticsearchConfig( - config.schema.validate({ - ssl: { certificateAuthorities: ['some-path', 'another-path'] }, - }) - ); - expect(configValue.ssl.certificateAuthorities).toEqual(['some-path', 'another-path']); + it('reads certificate authorities when ssl.truststore.path is specified', () => { + const configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { truststore: { path: 'some-path' } } }) + ); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when ssl.certificateAuthorities is specified', () => { + let configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { certificateAuthorities: 'some-path' } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { certificateAuthorities: ['some-path'] } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = new ElasticsearchConfig( + config.schema.validate({ + ssl: { certificateAuthorities: ['some-path', 'another-path'] }, + }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(2); + expect(configValue.ssl.certificateAuthorities).toEqual([ + 'content-of-some-path', + 'content-of-another-path', + ]); + }); + + it('reads certificate authorities when ssl.keystore.path, ssl.truststore.path, and ssl.certificateAuthorities are specified', () => { + const configValue = new ElasticsearchConfig( + config.schema.validate({ + ssl: { + keystore: { path: 'some-path' }, + truststore: { path: 'another-path' }, + certificateAuthorities: 'yet-another-path', + }, + }) + ); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificateAuthorities).toEqual([ + 'content-of-some-path.ca', + 'content-of-another-path', + 'content-of-yet-another-path', + ]); + }); + + it('reads a private key and certificate when ssl.keystore.path is specified', () => { + const configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) + ); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.ssl.key).toEqual('content-of-some-path.key'); + expect(configValue.ssl.certificate).toEqual('content-of-some-path.cert'); + }); + + it('reads a private key when ssl.key is specified', () => { + const configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { key: 'some-path' } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.key).toEqual('content-of-some-path'); + }); + + it('reads a certificate when ssl.certificate is specified', () => { + const configValue = new ElasticsearchConfig( + config.schema.validate({ ssl: { certificate: 'some-path' } }) + ); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.ssl.certificate).toEqual('content-of-some-path'); + }); +}); + +describe('throws when config is invalid', () => { + beforeAll(() => { + const realFs = jest.requireActual('fs'); + mockReadFileSync.mockImplementation((path: string) => realFs.readFileSync(path)); + const utils = jest.requireActual('../../utils'); + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Keystore(path, password) + ); + mockReadPkcs12Truststore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Truststore(path, password) + ); + }); + + it('throws if key is invalid', () => { + const value = { ssl: { key: '/invalid/key' } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/key'"` + ); + }); + + it('throws if certificate is invalid', () => { + const value = { ssl: { certificate: '/invalid/cert' } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/cert'"` + ); + }); + + it('throws if certificateAuthorities is invalid', () => { + const value = { ssl: { certificateAuthorities: '/invalid/ca' } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"ENOENT: no such file or directory, open '/invalid/ca'"`); + }); + + it('throws if keystore path is invalid', () => { + const value = { ssl: { keystore: { path: '/invalid/keystore' } } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/keystore'"` + ); + }); + + it('throws if keystore does not contain a key', () => { + mockReadPkcs12Keystore.mockReturnValueOnce({}); + const value = { ssl: { keystore: { path: 'some-path' } } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"Did not find key in Elasticsearch keystore."`); + }); + + it('throws if keystore does not contain a certificate', () => { + mockReadPkcs12Keystore.mockReturnValueOnce({ key: 'foo' }); + const value = { ssl: { keystore: { path: 'some-path' } } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"Did not find certificate in Elasticsearch keystore."`); + }); + + it('throws if truststore path is invalid', () => { + const value = { ssl: { keystore: { path: '/invalid/truststore' } } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/truststore'"` + ); + }); + + it('throws if key and keystore.path are both specified', () => { + const value = { ssl: { key: 'foo', keystore: { path: 'bar' } } }; + expect(() => config.schema.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[ssl]: cannot use [key] when [keystore.path] is specified"` + ); + }); + + it('throws if certificate and keystore.path are both specified', () => { + const value = { ssl: { certificate: 'foo', keystore: { path: 'bar' } } }; + expect(() => config.schema.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[ssl]: cannot use [certificate] when [keystore.path] is specified"` + ); + }); +}); + +describe('deprecations', () => { + it('logs a warning if elasticsearch.username is set to "elastic"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'elastic' }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.username] to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", + ] + `); + }); + + it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'otheruser' }); + expect(messages).toHaveLength(0); + }); + + it('does not log a warning if elasticsearch.username is unset', () => { + const { messages } = applyElasticsearchDeprecations({}); + expect(messages).toHaveLength(0); + }); + + it('logs a warning if ssl.key is set and ssl.certificate is not', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { key: '' } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.ssl.key] without [${CONFIG_PATH}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + ] + `); + }); + + it('logs a warning if ssl.certificate is set and ssl.key is not', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { certificate: '' } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.ssl.certificate] without [${CONFIG_PATH}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + ] + `); + }); + + it('does not log a warning if both ssl.key and ssl.certificate are set', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { key: '', certificate: '' } }); + expect(messages).toEqual([]); + }); }); test('#username throws if equal to "elastic", only while running from source', () => { diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 8d92b12ae4a779..5f06c51a53d535 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -19,55 +19,57 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { Duration } from 'moment'; -import { Logger } from '../logging'; +import { readFileSync } from 'fs'; +import { ConfigDeprecationProvider } from 'src/core/server'; +import { readPkcs12Keystore, readPkcs12Truststore } from '../../utils'; +import { ServiceConfigDescriptor } from '../internal_types'; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); export const DEFAULT_API_VERSION = 'master'; -export type ElasticsearchConfigType = TypeOf; +export type ElasticsearchConfigType = TypeOf; type SslConfigSchema = ElasticsearchConfigType['ssl']; -export const config = { - path: 'elasticsearch', - schema: schema.object({ - sniffOnStart: schema.boolean({ defaultValue: false }), - sniffInterval: schema.oneOf([schema.duration(), schema.literal(false)], { - defaultValue: false, - }), - sniffOnConnectionFault: schema.boolean({ defaultValue: false }), - hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { - defaultValue: 'http://localhost:9200', - }), - preserveHost: schema.boolean({ defaultValue: true }), - username: schema.maybe( - schema.conditional( - schema.contextRef('dist'), - false, - schema.string({ - validate: rawConfig => { - if (rawConfig === 'elastic') { - return ( - 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + - 'privilege-related issues. You should use the "kibana" user instead.' - ); - } - }, - }), - schema.string() - ) - ), - password: schema.maybe(schema.string()), - requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { - defaultValue: ['authorization'], - }), - customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), - shardTimeout: schema.duration({ defaultValue: '30s' }), - requestTimeout: schema.duration({ defaultValue: '30s' }), - pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), - startupTimeout: schema.duration({ defaultValue: '5s' }), - logQueries: schema.boolean({ defaultValue: false }), - ssl: schema.object({ +const configSchema = schema.object({ + sniffOnStart: schema.boolean({ defaultValue: false }), + sniffInterval: schema.oneOf([schema.duration(), schema.literal(false)], { + defaultValue: false, + }), + sniffOnConnectionFault: schema.boolean({ defaultValue: false }), + hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { + defaultValue: 'http://localhost:9200', + }), + preserveHost: schema.boolean({ defaultValue: true }), + username: schema.maybe( + schema.conditional( + schema.contextRef('dist'), + false, + schema.string({ + validate: rawConfig => { + if (rawConfig === 'elastic') { + return ( + 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + + 'privilege-related issues. You should use the "kibana" user instead.' + ); + } + }, + }), + schema.string() + ) + ), + password: schema.maybe(schema.string()), + requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { + defaultValue: ['authorization'], + }), + customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), + shardTimeout: schema.duration({ defaultValue: '30s' }), + requestTimeout: schema.duration({ defaultValue: '30s' }), + pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), + startupTimeout: schema.duration({ defaultValue: '5s' }), + logQueries: schema.boolean({ defaultValue: false }), + ssl: schema.object( + { verificationMode: schema.oneOf( [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], { defaultValue: 'full' } @@ -78,12 +80,60 @@ export const config = { certificate: schema.maybe(schema.string()), key: schema.maybe(schema.string()), keyPassphrase: schema.maybe(schema.string()), + keystore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + truststore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), alwaysPresentCertificate: schema.boolean({ defaultValue: false }), - }), - apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), - healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), - ignoreVersionMismatch: schema.boolean({ defaultValue: false }), - }), + }, + { + validate: rawConfig => { + if (rawConfig.key && rawConfig.keystore.path) { + return 'cannot use [key] when [keystore.path] is specified'; + } + if (rawConfig.certificate && rawConfig.keystore.path) { + return 'cannot use [certificate] when [keystore.path] is specified'; + } + }, + } + ), + apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), + healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), + ignoreVersionMismatch: schema.boolean({ defaultValue: false }), +}); + +const deprecations: ConfigDeprecationProvider = () => [ + (settings, fromPath, log) => { + const es = settings[fromPath]; + if (!es) { + return settings; + } + if (es.username === 'elastic') { + log( + `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana" user instead.` + ); + } + if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) { + log( + `Setting [${fromPath}.ssl.key] without [${fromPath}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` + ); + } else if (es.ssl?.certificate !== undefined && es.ssl?.key === undefined) { + log( + `Setting [${fromPath}.ssl.certificate] without [${fromPath}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` + ); + } + return settings; + }, +]; + +export const config: ServiceConfigDescriptor = { + path: 'elasticsearch', + schema: configSchema, + deprecations, }; export class ElasticsearchConfig { @@ -173,7 +223,7 @@ export class ElasticsearchConfig { */ public readonly ssl: Pick< SslConfigSchema, - Exclude + Exclude > & { certificateAuthorities?: string[] }; /** @@ -183,7 +233,7 @@ export class ElasticsearchConfig { */ public readonly customHeaders: ElasticsearchConfigType['customHeaders']; - constructor(rawConfig: ElasticsearchConfigType, log?: Logger) { + constructor(rawConfig: ElasticsearchConfigType) { this.ignoreVersionMismatch = rawConfig.ignoreVersionMismatch; this.apiVersion = rawConfig.apiVersion; this.logQueries = rawConfig.logQueries; @@ -202,24 +252,83 @@ export class ElasticsearchConfig { this.password = rawConfig.password; this.customHeaders = rawConfig.customHeaders; - const certificateAuthorities = Array.isArray(rawConfig.ssl.certificateAuthorities) - ? rawConfig.ssl.certificateAuthorities - : typeof rawConfig.ssl.certificateAuthorities === 'string' - ? [rawConfig.ssl.certificateAuthorities] - : undefined; + const { alwaysPresentCertificate, verificationMode } = rawConfig.ssl; + const { key, keyPassphrase, certificate, certificateAuthorities } = readKeyAndCerts(rawConfig); this.ssl = { - ...rawConfig.ssl, + alwaysPresentCertificate, + key, + keyPassphrase, + certificate, certificateAuthorities, + verificationMode, }; + } +} - if (this.username === 'elastic' && log !== undefined) { - // logger is optional / not used during tests - // TODO: logger can be removed when issue #40255 is resolved to support deprecations in NP config service - log.warn( - `Setting the elasticsearch username to "elastic" is deprecated. You should use the "kibana" user instead.`, - { tags: ['deprecation'] } - ); +const readKeyAndCerts = (rawConfig: ElasticsearchConfigType) => { + let key: string | undefined; + let keyPassphrase: string | undefined; + let certificate: string | undefined; + let certificateAuthorities: string[] | undefined; + + const addCAs = (ca: string[] | undefined) => { + if (ca && ca.length) { + certificateAuthorities = [...(certificateAuthorities || []), ...ca]; + } + }; + + if (rawConfig.ssl.keystore?.path) { + const keystore = readPkcs12Keystore( + rawConfig.ssl.keystore.path, + rawConfig.ssl.keystore.password + ); + if (!keystore.key) { + throw new Error(`Did not find key in Elasticsearch keystore.`); + } else if (!keystore.cert) { + throw new Error(`Did not find certificate in Elasticsearch keystore.`); + } + key = keystore.key; + certificate = keystore.cert; + addCAs(keystore.ca); + } else { + if (rawConfig.ssl.key) { + key = readFile(rawConfig.ssl.key); + keyPassphrase = rawConfig.ssl.keyPassphrase; + } + if (rawConfig.ssl.certificate) { + certificate = readFile(rawConfig.ssl.certificate); } } -} + + if (rawConfig.ssl.truststore?.path) { + const ca = readPkcs12Truststore( + rawConfig.ssl.truststore.path, + rawConfig.ssl.truststore.password + ); + addCAs(ca); + } + + const ca = rawConfig.ssl.certificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + if (paths.length > 0) { + for (const path of paths) { + parsed.push(readFile(path)); + } + addCAs(parsed); + } + } + + return { + key, + keyPassphrase, + certificate, + certificateAuthorities, + }; +}; + +const readFile = (file: string) => { + return readFileSync(file, 'utf8'); +}; diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index aefc8b1fdf047e..9f694ac1c46da2 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -196,7 +196,11 @@ Object { undefined, ], "ssl": Object { + "alwaysPresentCertificate": undefined, + "certificate": undefined, "certificateAuthorities": undefined, + "key": undefined, + "keyPassphrase": undefined, "verificationMode": "none", }, } diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index de32e7f6cf2258..db3fda3a504ab5 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -52,7 +52,7 @@ export class ElasticsearchService implements CoreService('elasticsearch') - .pipe(map(rawConfig => new ElasticsearchConfig(rawConfig, coreContext.logger.get('config')))); + .pipe(map(rawConfig => new ElasticsearchConfig(rawConfig))); } public async setup(deps: SetupDeps): Promise { diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 8856eb95ba722b..28933a035c8709 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -67,10 +67,12 @@ Object { ], "clientAuthentication": "none", "enabled": false, + "keystore": Object {}, "supportedProtocols": Array [ "TLSv1.1", "TLSv1.2", ], + "truststore": Object {}, }, "xsrf": Object { "disableProtection": false, @@ -87,24 +89,6 @@ exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = ` exports[`throws if invalid hostname 1`] = `"[host]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`; -exports[`with TLS should accept known protocols\` 1`] = ` -"[ssl.supportedProtocols.0]: types that failed validation: -- [ssl.supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500] -- [ssl.supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] -- [ssl.supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" -`; - -exports[`with TLS should accept known protocols\` 2`] = ` -"[ssl.supportedProtocols.3]: types that failed validation: -- [ssl.supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500] -- [ssl.supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] -- [ssl.supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" -`; - -exports[`with TLS throws if TLS is enabled but \`certificate\` is not specified 1`] = `"[ssl]: must specify [certificate] and [key] when ssl is enabled"`; - -exports[`with TLS throws if TLS is enabled but \`key\` is not specified 1`] = `"[ssl]: must specify [certificate] and [key] when ssl is enabled"`; - exports[`with TLS throws if TLS is enabled but \`redirectHttpFromPort\` is equal to \`port\` 1`] = `"Kibana does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`; exports[`with compression accepts valid referrer whitelist 1`] = ` diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 3dc5fa48bc3665..7ac707b0f3d83e 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -18,7 +18,7 @@ */ import uuid from 'uuid'; -import { config, HttpConfig } from '.'; +import { config } from '.'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; @@ -108,28 +108,6 @@ test('throws if xsrf.whitelist element does not start with a slash', () => { }); describe('with TLS', () => { - test('throws if TLS is enabled but `key` is not specified', () => { - const httpSchema = config.schema; - const obj = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); - }); - - test('throws if TLS is enabled but `certificate` is not specified', () => { - const httpSchema = config.schema; - const obj = { - ssl: { - enabled: true, - key: '/path/to/key', - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); - }); - test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { const httpSchema = config.schema; const obj = { @@ -143,189 +121,16 @@ describe('with TLS', () => { }; expect(() => httpSchema.validate(obj)).toThrowErrorMatchingSnapshot(); }); +}); - test('throws if TLS is not enabled but `clientAuthentication` is `optional`', () => { - const httpSchema = config.schema; - const obj = { - port: 1234, - ssl: { - enabled: false, - clientAuthentication: 'optional', - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( - `"[ssl]: must enable ssl to use [clientAuthentication]"` - ); - }); - - test('throws if TLS is not enabled but `clientAuthentication` is `required`', () => { - const httpSchema = config.schema; - const obj = { - port: 1234, - ssl: { - enabled: false, - clientAuthentication: 'required', - }, - }; - expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( - `"[ssl]: must enable ssl to use [clientAuthentication]"` - ); - }); - - test('can specify `none` for [clientAuthentication] if ssl is not enabled', () => { - const obj = { - ssl: { - enabled: false, - clientAuthentication: 'none', - }, - }; - - const configValue = config.schema.validate(obj); - expect(configValue.ssl.clientAuthentication).toBe('none'); - }); - - test('can specify single `certificateAuthority` as a string', () => { - const obj = { - ssl: { - certificate: '/path/to/certificate', - certificateAuthorities: '/authority/', - enabled: true, - key: '/path/to/key', - }, - }; - - const configValue = config.schema.validate(obj); - expect(configValue.ssl.certificateAuthorities).toBe('/authority/'); - }); - - test('can specify socket timeouts', () => { - const obj = { - keepaliveTimeout: 1e5, - socketTimeout: 5e5, - }; - const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); - expect(keepaliveTimeout).toBe(1e5); - expect(socketTimeout).toBe(5e5); - }); - - test('can specify several `certificateAuthorities`', () => { - const obj = { - ssl: { - certificate: '/path/to/certificate', - certificateAuthorities: ['/authority/1', '/authority/2'], - enabled: true, - key: '/path/to/key', - }, - }; - - const configValue = config.schema.validate(obj); - expect(configValue.ssl.certificateAuthorities).toEqual(['/authority/1', '/authority/2']); - }); - - test('accepts known protocols`', () => { - const httpSchema = config.schema; - const singleKnownProtocol = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['TLSv1'], - }, - }; - - const allKnownProtocols = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], - }, - }; - - const singleKnownProtocolConfig = httpSchema.validate(singleKnownProtocol); - expect(singleKnownProtocolConfig.ssl.supportedProtocols).toEqual(['TLSv1']); - - const allKnownProtocolsConfig = httpSchema.validate(allKnownProtocols); - expect(allKnownProtocolsConfig.ssl.supportedProtocols).toEqual(['TLSv1', 'TLSv1.1', 'TLSv1.2']); - }); - - test('should accept known protocols`', () => { - const httpSchema = config.schema; - - const singleUnknownProtocol = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['SOMEv100500'], - }, - }; - - const allKnownWithOneUnknownProtocols = { - ssl: { - certificate: '/path/to/certificate', - enabled: true, - key: '/path/to/key', - supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'SOMEv100500'], - }, - }; - - expect(() => httpSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingSnapshot(); - expect(() => - httpSchema.validate(allKnownWithOneUnknownProtocols) - ).toThrowErrorMatchingSnapshot(); - }); - - test('HttpConfig instance should properly interpret `none` client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - clientAuthentication: 'none', - }, - }), - {} as any - ); - - expect(httpConfig.ssl.requestCert).toBe(false); - expect(httpConfig.ssl.rejectUnauthorized).toBe(false); - }); - - test('HttpConfig instance should properly interpret `optional` client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - clientAuthentication: 'optional', - }, - }), - {} as any - ); - - expect(httpConfig.ssl.requestCert).toBe(true); - expect(httpConfig.ssl.rejectUnauthorized).toBe(false); - }); - - test('HttpConfig instance should properly interpret `required` client authentication', () => { - const httpConfig = new HttpConfig( - config.schema.validate({ - ssl: { - enabled: true, - key: 'some-key-path', - certificate: 'some-certificate-path', - clientAuthentication: 'required', - }, - }), - {} as any - ); - - expect(httpConfig.ssl.requestCert).toBe(true); - expect(httpConfig.ssl.rejectUnauthorized).toBe(true); - }); +test('can specify socket timeouts', () => { + const obj = { + keepaliveTimeout: 1e5, + socketTimeout: 5e5, + }; + const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); + expect(keepaliveTimeout).toBe(1e5); + expect(socketTimeout).toBe(5e5); }); describe('with compression', () => { diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index df357aeaf2731d..df7b4b5af4267b 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -18,11 +18,7 @@ */ import { Server } from 'http'; - -jest.mock('fs', () => ({ - readFileSync: jest.fn(), -})); - +import { readFileSync } from 'fs'; import supertest from 'supertest'; import { ByteSizeValue, schema } from '@kbn/config-schema'; @@ -39,6 +35,7 @@ import { loggingServiceMock } from '../logging/logging_service.mock'; import { HttpServer } from './http_server'; import { Readable } from 'stream'; import { RequestHandlerContext } from 'kibana/server'; +import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; const cookieOptions = { name: 'sid', @@ -55,6 +52,14 @@ const loggingService = loggingServiceMock.create(); const logger = loggingService.get(); const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); +let certificate: string; +let key: string; + +beforeAll(() => { + certificate = readFileSync(KBN_CERT_PATH, 'utf8'); + key = readFileSync(KBN_KEY_PATH, 'utf8'); +}); + beforeEach(() => { config = { host: '127.0.0.1', @@ -68,10 +73,10 @@ beforeEach(() => { ...config, ssl: { enabled: true, - certificate: '/certificate', + certificate, cipherSuites: ['cipherSuite'], getSecureOptions: () => 0, - key: '/key', + key, redirectHttpFromPort: config.port + 1, }, } as HttpConfig; diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 22468a5b252f42..747fd5a10b168d 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -17,7 +17,6 @@ * under the License. */ -import { readFileSync } from 'fs'; import { Lifecycle, Request, ResponseToolkit, Server, ServerOptions, Util } from 'hapi'; import Hoek from 'hoek'; import { ServerOptions as TLSOptions } from 'https'; @@ -66,14 +65,12 @@ export function getServerOptions(config: HttpConfig, { configureTLS = true } = { // TODO: Hapi types have a typo in `tls` property type definition: `https.RequestOptions` is used instead of // `https.ServerOptions`, and `honorCipherOrder` isn't presented in `https.RequestOptions`. const tlsOptions: TLSOptions = { - ca: - config.ssl.certificateAuthorities && - config.ssl.certificateAuthorities.map(caFilePath => readFileSync(caFilePath)), - cert: readFileSync(ssl.certificate!), + ca: ssl.certificateAuthorities, + cert: ssl.certificate, ciphers: config.ssl.cipherSuites.join(':'), // We use the server's cipher order rather than the client's to prevent the BEAST attack. honorCipherOrder: true, - key: readFileSync(ssl.key!), + key: ssl.key, passphrase: ssl.keyPassphrase, secureOptions: ssl.getSecureOptions(), requestCert: ssl.requestCert, diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index c3b9b20d848657..a1523781010d47 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -142,6 +142,61 @@ describe('Handler', () => { statusCode: 400, }); }); + + it('accept to receive an array payload', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + let body: any = null; + router.post( + { + path: '/', + validate: { + body: schema.arrayOf(schema.object({ foo: schema.string() })), + }, + }, + (context, req, res) => { + body = req.body; + return res.ok({ body: 'ok' }); + } + ); + await server.start(); + + await supertest(innerServer.listener) + .post('/') + .send([{ foo: 'bar' }, { foo: 'dolly' }]) + .expect(200); + + expect(body).toEqual([{ foo: 'bar' }, { foo: 'dolly' }]); + }); + + it('accept to receive a json primitive payload', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + let body: any = null; + router.post( + { + path: '/', + validate: { + body: schema.number(), + }, + }, + (context, req, res) => { + body = req.body; + return res.ok({ body: 'ok' }); + } + ); + await server.start(); + + await supertest(innerServer.listener) + .post('/') + .type('json') + .send('12') + .expect(200); + + expect(body).toEqual(12); + }); }); describe('handleLegacyErrors', () => { diff --git a/src/core/server/http/router/validator/validator.test.ts b/src/core/server/http/router/validator/validator.test.ts index 729eb1b60c10a8..e972e2075e7056 100644 --- a/src/core/server/http/router/validator/validator.test.ts +++ b/src/core/server/http/router/validator/validator.test.ts @@ -132,4 +132,62 @@ describe('Router validator', () => { 'The validation rule provided in the handler is not valid' ); }); + + it('should validate and infer type when data is an array', () => { + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.string()), + }).getBody(['foo', 'bar']) + ).toStrictEqual(['foo', 'bar']); + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody([1, 2, 3]) + ).toStrictEqual([1, 2, 3]); + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.object({ foo: schema.string() })), + }).getBody([{ foo: 'bar' }, { foo: 'dolly' }]) + ).toStrictEqual([{ foo: 'bar' }, { foo: 'dolly' }]); + + expect(() => + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody(['foo', 'bar', 'dolly']) + ).toThrowError('[0]: expected value of type [number] but got [string]'); + expect(() => + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody({ foo: 'bar' }) + ).toThrowError('expected value of type [array] but got [Object]'); + }); + + it('should validate and infer type when data is a primitive', () => { + expect( + RouteValidator.from({ + body: schema.string(), + }).getBody('foobar') + ).toStrictEqual('foobar'); + expect( + RouteValidator.from({ + body: schema.number(), + }).getBody(42) + ).toStrictEqual(42); + expect( + RouteValidator.from({ + body: schema.boolean(), + }).getBody(true) + ).toStrictEqual(true); + + expect(() => + RouteValidator.from({ + body: schema.string(), + }).getBody({ foo: 'bar' }) + ).toThrowError('expected value of type [string] but got [Object]'); + expect(() => + RouteValidator.from({ + body: schema.number(), + }).getBody('foobar') + ).toThrowError('expected value of type [number] but got [string]'); + }); }); diff --git a/src/core/server/http/router/validator/validator.ts b/src/core/server/http/router/validator/validator.ts index 65c0a934e6ef00..97dd2bc894f813 100644 --- a/src/core/server/http/router/validator/validator.ts +++ b/src/core/server/http/router/validator/validator.ts @@ -274,7 +274,7 @@ export class RouteValidator

{ // if options.body.output === 'stream' return schema.stream(); } else { - return schema.maybe(schema.nullable(schema.object({}, { allowUnknowns: true }))); + return schema.maybe(schema.nullable(schema.any({}))); } } } diff --git a/src/core/server/http/ssl_config.test.mocks.ts b/src/core/server/http/ssl_config.test.mocks.ts new file mode 100644 index 00000000000000..ab98c3a27920c5 --- /dev/null +++ b/src/core/server/http/ssl_config.test.mocks.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const mockReadFileSync = jest.fn(); +jest.mock('fs', () => { + return { readFileSync: mockReadFileSync }; +}); + +export const mockReadPkcs12Keystore = jest.fn(); +export const mockReadPkcs12Truststore = jest.fn(); +jest.mock('../../utils', () => ({ + readPkcs12Keystore: mockReadPkcs12Keystore, + readPkcs12Truststore: mockReadPkcs12Truststore, +})); diff --git a/src/core/server/http/ssl_config.test.ts b/src/core/server/http/ssl_config.test.ts new file mode 100644 index 00000000000000..738f86f7a69eb8 --- /dev/null +++ b/src/core/server/http/ssl_config.test.ts @@ -0,0 +1,363 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + mockReadFileSync, + mockReadPkcs12Keystore, + mockReadPkcs12Truststore, +} from './ssl_config.test.mocks'; + +import { sslSchema, SslConfig } from './ssl_config'; + +describe('#SslConfig', () => { + const createConfig = (obj: any) => new SslConfig(sslSchema.validate(obj)); + + beforeEach(() => { + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + mockReadPkcs12Keystore.mockReset(); + mockReadPkcs12Keystore.mockImplementation((path: string) => ({ + key: `content-of-${path}.key`, + cert: `content-of-${path}.cert`, + ca: [`content-of-${path}.ca`], + })); + mockReadPkcs12Truststore.mockReset(); + mockReadPkcs12Truststore.mockImplementation((path: string) => [`content-of-${path}`]); + }); + + describe('throws when config is invalid', () => { + beforeEach(() => { + const realFs = jest.requireActual('fs'); + mockReadFileSync.mockImplementation((path: string) => realFs.readFileSync(path)); + const utils = jest.requireActual('../../utils'); + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Keystore(path, password) + ); + mockReadPkcs12Truststore.mockImplementation((path: string, password?: string) => + utils.readPkcs12Truststore(path, password) + ); + }); + + test('throws if `key` is invalid', () => { + const obj = { key: '/invalid/key', certificate: '/valid/certificate' }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/key'"` + ); + }); + + test('throws if `certificate` is invalid', () => { + mockReadFileSync.mockImplementationOnce((path: string) => `content-of-${path}`); + const obj = { key: '/valid/key', certificate: '/invalid/certificate' }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/certificate'"` + ); + }); + + test('throws if `certificateAuthorities` is invalid', () => { + const obj = { certificateAuthorities: '/invalid/ca' }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/ca'"` + ); + }); + + test('throws if `keystore.path` is invalid', () => { + const obj = { keystore: { path: '/invalid/keystore' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/keystore'"` + ); + }); + + test('throws if `keystore.path` does not contain a private key', () => { + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => ({ + key: undefined, + certificate: 'foo', + })); + const obj = { keystore: { path: 'some-path' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"Did not find private key in keystore at [keystore.path]."` + ); + }); + + test('throws if `keystore.path` does not contain a certificate', () => { + mockReadPkcs12Keystore.mockImplementation((path: string, password?: string) => ({ + key: 'foo', + certificate: undefined, + })); + const obj = { keystore: { path: 'some-path' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"Did not find certificate in keystore at [keystore.path]."` + ); + }); + + test('throws if `truststore.path` is invalid', () => { + const obj = { truststore: { path: '/invalid/truststore' } }; + expect(() => createConfig(obj)).toThrowErrorMatchingInlineSnapshot( + `"ENOENT: no such file or directory, open '/invalid/truststore'"` + ); + }); + }); + + describe('reads files', () => { + it('reads certificate authorities when `keystore.path` is specified', () => { + const configValue = createConfig({ keystore: { path: 'some-path' } }); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path.ca']); + }); + + it('reads certificate authorities when `truststore.path` is specified', () => { + const configValue = createConfig({ truststore: { path: 'some-path' } }); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path']); + }); + + it('reads certificate authorities when `certificateAuthorities` is specified', () => { + let configValue = createConfig({ certificateAuthorities: 'some-path' }); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = createConfig({ certificateAuthorities: ['some-path'] }); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual(['content-of-some-path']); + + mockReadFileSync.mockClear(); + configValue = createConfig({ certificateAuthorities: ['some-path', 'another-path'] }); + expect(mockReadFileSync).toHaveBeenCalledTimes(2); + expect(configValue.certificateAuthorities).toEqual([ + 'content-of-some-path', + 'content-of-another-path', + ]); + }); + + it('reads certificate authorities when `keystore.path`, `truststore.path`, and `certificateAuthorities` are specified', () => { + const configValue = createConfig({ + keystore: { path: 'some-path' }, + truststore: { path: 'another-path' }, + certificateAuthorities: 'yet-another-path', + }); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + expect(configValue.certificateAuthorities).toEqual([ + 'content-of-some-path.ca', + 'content-of-another-path', + 'content-of-yet-another-path', + ]); + }); + + it('reads a private key and certificate when `keystore.path` is specified', () => { + const configValue = createConfig({ keystore: { path: 'some-path' } }); + expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); + expect(configValue.key).toEqual('content-of-some-path.key'); + expect(configValue.certificate).toEqual('content-of-some-path.cert'); + }); + + it('reads a private key and certificate when `key` and `certificate` are specified', () => { + const configValue = createConfig({ key: 'some-path', certificate: 'another-path' }); + expect(mockReadFileSync).toHaveBeenCalledTimes(2); + expect(configValue.key).toEqual('content-of-some-path'); + expect(configValue.certificate).toEqual('content-of-another-path'); + }); + }); +}); + +describe('#sslSchema', () => { + describe('throws when config is invalid', () => { + test('throws if both `key` and `keystore.path` are specified', () => { + const obj = { + key: '/path/to/key', + keystore: { + path: 'path/to/keystore', + }, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"cannot use [key] when [keystore.path] is specified"` + ); + }); + + test('throws if both `certificate` and `keystore.path` are specified', () => { + const obj = { + certificate: '/path/to/certificate', + keystore: { + path: 'path/to/keystore', + }, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"cannot use [certificate] when [keystore.path] is specified"` + ); + }); + + test('throws if TLS is enabled but `certificate` is specified and `key` is not', () => { + const obj = { + certificate: '/path/to/certificate', + enabled: true, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled"` + ); + }); + + test('throws if TLS is enabled but `key` is specified and `certificate` is not', () => { + const obj = { + enabled: true, + key: '/path/to/key', + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled"` + ); + }); + + test('throws if TLS is enabled but `key`, `certificate`, and `keystore.path` are not specified', () => { + const obj = { + enabled: true, + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled"` + ); + }); + + test('throws if TLS is not enabled but `clientAuthentication` is `optional`', () => { + const obj = { + enabled: false, + clientAuthentication: 'optional', + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must enable ssl to use [clientAuthentication]"` + ); + }); + + test('throws if TLS is not enabled but `clientAuthentication` is `required`', () => { + const obj = { + enabled: false, + clientAuthentication: 'required', + }; + expect(() => sslSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"must enable ssl to use [clientAuthentication]"` + ); + }); + }); + + describe('#supportedProtocols', () => { + test('accepts known protocols`', () => { + const singleKnownProtocol = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1'], + }; + + const allKnownProtocols = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], + }; + + const singleKnownProtocolConfig = sslSchema.validate(singleKnownProtocol); + expect(singleKnownProtocolConfig.supportedProtocols).toEqual(['TLSv1']); + + const allKnownProtocolsConfig = sslSchema.validate(allKnownProtocols); + expect(allKnownProtocolsConfig.supportedProtocols).toEqual(['TLSv1', 'TLSv1.1', 'TLSv1.2']); + }); + + test('rejects unknown protocols`', () => { + const singleUnknownProtocol = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['SOMEv100500'], + }; + + const allKnownWithOneUnknownProtocols = { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + supportedProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'SOMEv100500'], + }; + + expect(() => sslSchema.validate(singleUnknownProtocol)).toThrowErrorMatchingInlineSnapshot(` +"[supportedProtocols.0]: types that failed validation: +- [supportedProtocols.0.0]: expected value to equal [TLSv1] but got [SOMEv100500] +- [supportedProtocols.0.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] +- [supportedProtocols.0.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +`); + expect(() => sslSchema.validate(allKnownWithOneUnknownProtocols)) + .toThrowErrorMatchingInlineSnapshot(` +"[supportedProtocols.3]: types that failed validation: +- [supportedProtocols.3.0]: expected value to equal [TLSv1] but got [SOMEv100500] +- [supportedProtocols.3.1]: expected value to equal [TLSv1.1] but got [SOMEv100500] +- [supportedProtocols.3.2]: expected value to equal [TLSv1.2] but got [SOMEv100500]" +`); + }); + }); + + describe('#clientAuthentication', () => { + test('can specify `none` client authentication when ssl is not enabled', () => { + const obj = { + enabled: false, + clientAuthentication: 'none', + }; + + const configValue = sslSchema.validate(obj); + expect(configValue.clientAuthentication).toBe('none'); + }); + + test('should properly interpret `none` client authentication when ssl is enabled', () => { + const sslConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'none', + }) + ); + + expect(sslConfig.requestCert).toBe(false); + expect(sslConfig.rejectUnauthorized).toBe(false); + }); + + test('should properly interpret `optional` client authentication when ssl is enabled', () => { + const sslConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'optional', + }) + ); + + expect(sslConfig.requestCert).toBe(true); + expect(sslConfig.rejectUnauthorized).toBe(false); + }); + + test('should properly interpret `required` client authentication when ssl is enabled', () => { + const sslConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + key: 'some-key-path', + certificate: 'some-certificate-path', + clientAuthentication: 'required', + }) + ); + + expect(sslConfig.requestCert).toBe(true); + expect(sslConfig.rejectUnauthorized).toBe(true); + }); + }); +}); diff --git a/src/core/server/http/ssl_config.ts b/src/core/server/http/ssl_config.ts index 55d6ebff93ce78..0096eeb0925658 100644 --- a/src/core/server/http/ssl_config.ts +++ b/src/core/server/http/ssl_config.ts @@ -19,6 +19,8 @@ import { schema, TypeOf } from '@kbn/config-schema'; import crypto from 'crypto'; +import { readFileSync } from 'fs'; +import { readPkcs12Keystore, readPkcs12Truststore } from '../../utils'; // `crypto` type definitions doesn't currently include `crypto.constants`, see // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fa5baf1733f49cf26228a4e509914572c1b74adf/types/node/v6/index.d.ts#L3412 @@ -44,6 +46,14 @@ export const sslSchema = schema.object( }), key: schema.maybe(schema.string()), keyPassphrase: schema.maybe(schema.string()), + keystore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + truststore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), redirectHttpFromPort: schema.maybe(schema.number()), supportedProtocols: schema.arrayOf( schema.oneOf([schema.literal('TLSv1'), schema.literal('TLSv1.1'), schema.literal('TLSv1.2')]), @@ -56,8 +66,16 @@ export const sslSchema = schema.object( }, { validate: ssl => { - if (ssl.enabled && (!ssl.key || !ssl.certificate)) { - return 'must specify [certificate] and [key] when ssl is enabled'; + if (ssl.key && ssl.keystore.path) { + return 'cannot use [key] when [keystore.path] is specified'; + } + + if (ssl.certificate && ssl.keystore.path) { + return 'cannot use [certificate] when [keystore.path] is specified'; + } + + if (ssl.enabled && (!ssl.key || !ssl.certificate) && !ssl.keystore.path) { + return 'must specify [certificate] and [key] -- or [keystore.path] -- when ssl is enabled'; } if (!ssl.enabled && ssl.clientAuthentication !== 'none') { @@ -88,14 +106,49 @@ export class SslConfig { constructor(config: SslConfigType) { this.enabled = config.enabled; this.redirectHttpFromPort = config.redirectHttpFromPort; - this.key = config.key; - this.certificate = config.certificate; - this.certificateAuthorities = this.initCertificateAuthorities(config.certificateAuthorities); - this.keyPassphrase = config.keyPassphrase; this.cipherSuites = config.cipherSuites; this.supportedProtocols = config.supportedProtocols; this.requestCert = config.clientAuthentication !== 'none'; this.rejectUnauthorized = config.clientAuthentication === 'required'; + + const addCAs = (ca: string[] | undefined) => { + if (ca && ca.length) { + this.certificateAuthorities = [...(this.certificateAuthorities || []), ...ca]; + } + }; + + if (config.keystore?.path) { + const { key, cert, ca } = readPkcs12Keystore(config.keystore.path, config.keystore.password); + if (!key) { + throw new Error(`Did not find private key in keystore at [keystore.path].`); + } else if (!cert) { + throw new Error(`Did not find certificate in keystore at [keystore.path].`); + } + this.key = key; + this.certificate = cert; + addCAs(ca); + } else if (config.key && config.certificate) { + this.key = readFile(config.key); + this.keyPassphrase = config.keyPassphrase; + this.certificate = readFile(config.certificate); + } + + if (config.truststore?.path) { + const ca = readPkcs12Truststore(config.truststore.path, config.truststore.password); + addCAs(ca); + } + + const ca = config.certificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + if (paths.length > 0) { + for (const path of paths) { + parsed.push(readFile(path)); + } + addCAs(parsed); + } + } } /** @@ -117,12 +170,8 @@ export class SslConfig { : secureOptions | secureOption; // eslint-disable-line no-bitwise }, 0); } - - private initCertificateAuthorities(certificateAuthorities?: string[] | string) { - if (certificateAuthorities === undefined || Array.isArray(certificateAuthorities)) { - return certificateAuthorities; - } - - return [certificateAuthorities]; - } } + +const readFile = (file: string) => { + return readFileSync(file, 'utf8'); +}; diff --git a/src/core/server/plugins/plugins_system.test.ts b/src/core/server/plugins/plugins_system.test.ts index 18c04af3bb6413..22dfbeecbaedd8 100644 --- a/src/core/server/plugins/plugins_system.test.ts +++ b/src/core/server/plugins/plugins_system.test.ts @@ -414,3 +414,51 @@ test('`startPlugins` only starts plugins that were setup', async () => { ] `); }); + +describe('setup', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + it('throws timeout error if "setup" was not completed in 30 sec.', async () => { + const plugin: PluginWrapper = createPlugin('timeout-setup'); + jest.spyOn(plugin, 'setup').mockImplementation(() => new Promise(i => i)); + pluginsSystem.addPlugin(plugin); + mockCreatePluginSetupContext.mockImplementation(() => ({})); + + const promise = pluginsSystem.setupPlugins(setupDeps); + jest.runAllTimers(); + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Setup lifecycle of "timeout-setup" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); +}); + +describe('start', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + it('throws timeout error if "start" was not completed in 30 sec.', async () => { + const plugin: PluginWrapper = createPlugin('timeout-start'); + jest.spyOn(plugin, 'setup').mockResolvedValue({}); + jest.spyOn(plugin, 'start').mockImplementation(() => new Promise(i => i)); + + pluginsSystem.addPlugin(plugin); + mockCreatePluginSetupContext.mockImplementation(() => ({})); + mockCreatePluginStartContext.mockImplementation(() => ({})); + + await pluginsSystem.setupPlugins(setupDeps); + const promise = pluginsSystem.startPlugins(startDeps); + jest.runAllTimers(); + + await expect(promise).rejects.toMatchInlineSnapshot( + `[Error: Start lifecycle of "timeout-start" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.]` + ); + }); +}); diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index f437b51e5b07a7..dd2df7c8e01d1f 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -23,7 +23,9 @@ import { PluginWrapper } from './plugin'; import { DiscoveredPlugin, PluginName, PluginOpaqueId } from './types'; import { createPluginSetupContext, createPluginStartContext } from './plugin_context'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; +import { withTimeout } from '../../utils'; +const Sec = 1000; /** @internal */ export class PluginsSystem { private readonly plugins = new Map(); @@ -85,14 +87,16 @@ export class PluginsSystem { return depContracts; }, {} as Record); - contracts.set( - pluginName, - await plugin.setup( + const contract = await withTimeout({ + promise: plugin.setup( createPluginSetupContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Setup lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + contracts.set(pluginName, contract); this.satupPlugins.push(pluginName); } @@ -121,13 +125,16 @@ export class PluginsSystem { return depContracts; }, {} as Record); - contracts.set( - pluginName, - await plugin.start( + const contract = await withTimeout({ + promise: plugin.start( createPluginStartContext(this.coreContext, deps, plugin), pluginDepContracts - ) - ); + ), + timeout: 30 * Sec, + errorMessage: `Start lifecycle of "${pluginName}" plugin wasn't completed in 30sec. Consider disabling the plugin and re-start.`, + }); + + contracts.set(pluginName, contract); } return contracts; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index ef5bcecc948a96..70b8b2878c15b2 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -141,7 +141,7 @@ export class SavedObjectsRepository { callCluster: APICaller, extraTypes: string[] = [], injectedConstructor: any = SavedObjectsRepository - ) { + ): ISavedObjectsRepository { const mappings = migrator.getActiveMappings(); const allTypes = Object.keys(getRootPropertiesObjects(mappings)); const serializer = new SavedObjectsSerializer(schema); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index e41f045622a42c..bf7dc14c732659 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -450,11 +450,11 @@ export interface AuthToolkit { export class BasePath { // @internal constructor(serverBasePath?: string); - get: (request: KibanaRequest | LegacyRequest) => string; + get: (request: LegacyRequest | KibanaRequest) => string; prepend: (path: string) => string; remove: (path: string) => string; readonly serverBasePath: string; - set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; + set: (request: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void; } // Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts @@ -888,7 +888,7 @@ export type KibanaResponseFactory = typeof kibanaResponseFactory; // @public export const kibanaResponseFactory: { - custom: | Buffer | Stream | { + custom: | { message: string | Error; attributes?: Record | undefined; } | undefined>(options: CustomHttpResponseOptions) => KibanaResponse; @@ -899,9 +899,9 @@ export const kibanaResponseFactory: { conflict: (options?: ErrorHttpResponseOptions) => KibanaResponse; internalError: (options?: ErrorHttpResponseOptions) => KibanaResponse; customError: (options: CustomHttpResponseOptions) => KibanaResponse; - redirected: (options: RedirectResponseOptions) => KibanaResponse | Buffer | Stream>; - ok: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; - accepted: (options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + redirected: (options: RedirectResponseOptions) => KibanaResponse>; + ok: (options?: HttpResponseOptions) => KibanaResponse>; + accepted: (options?: HttpResponseOptions) => KibanaResponse>; noContent: (options?: HttpResponseOptions) => KibanaResponse; }; @@ -1796,7 +1796,7 @@ export class SavedObjectsRepository { // Warning: (ae-forgotten-export) The symbol "KibanaMigrator" needs to be exported by the entry point index.d.ts // // @internal - static createRepository(migrator: KibanaMigrator, schema: SavedObjectsSchema, config: LegacyConfig, indexName: string, callCluster: APICaller, extraTypes?: string[], injectedConstructor?: any): any; + static createRepository(migrator: KibanaMigrator, schema: SavedObjectsSchema, config: LegacyConfig, indexName: string, callCluster: APICaller, extraTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index eced24b84908c2..611842e8a7de0d 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -256,6 +256,10 @@ export class Server { ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); + this.configService.addDeprecationProvider( + elasticsearchConfig.path, + elasticsearchConfig.deprecations! + ); this.configService.addDeprecationProvider( uiSettingsConfig.path, uiSettingsConfig.deprecations! diff --git a/src/core/utils/crypto/__fixtures__/README.md b/src/core/utils/crypto/__fixtures__/README.md new file mode 100644 index 00000000000000..2652b16c420f0c --- /dev/null +++ b/src/core/utils/crypto/__fixtures__/README.md @@ -0,0 +1,44 @@ +# PKCS12 Test Fixtures + +These PKCS12 files are used to test different scenarios. Each has an empty password. + +Including `-noiter` uses a single encryption iteration, and `-nomaciter` uses a single MAC verification iteration. +This makes each P12 keystore much quicker to parse. + +Commands to generate files: + +```shell +# Generate a PKCS12 file with an EE cert and CA cert, but no EE key +cat elasticsearch.crt ca.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -nokeys -out no_key.p12 + +# Generate a PKCS12 file with an EE key and EE cert, but no CA cert +cat elasticsearch.key elasticsearch.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -out no_ca.p12 + +# Generate a PKCS12 file with an EE key, EE cert, and two CA certs +cat elasticsearch.key elasticsearch.crt ca.crt ca.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -out two_cas.p12 + +# Generate a PKCS12 file with two EE keys and EE certs +cat elasticsearch.key elasticsearch.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -out two_keys.p12 +cat kibana.key kibana.crt | openssl pkcs12 -export -noiter -nomaciter -passout pass: -name 2 -out tmp.p12 +keytool -importkeystore -srckeystore tmp.p12 -srcstorepass '' -destkeystore two_keys.p12 -deststorepass '' -deststoretype PKCS12 +rm tmp.p12 +``` + +No commonly available tools seem to be able to generate a PKCS12 file with a key and no certificate, so we use node-forge to do that: + +```js +const utils = require('@kbn/dev-utils'); +const forge = require('node-forge'); +const fs = require('fs'); + +const pemCA = fs.readFileSync(utils.CA_CERT_PATH, 'utf8'); +const pemKey = fs.readFileSync(utils.ES_KEY_PATH, 'utf8'); +const privateKey = forge.pki.privateKeyFromPem(pemKey); + +const p12Asn = forge.pkcs12.toPkcs12Asn1(privateKey, pemCA, null, { + useMac: false, + generateLocalKeyId: false, +}); +const p12Der = forge.asn1.toDer(p12Asn).getBytes(); +fs.writeFileSync('no_cert.p12', p12Der, { encoding: 'binary' }); +``` diff --git a/src/core/utils/crypto/__fixtures__/index.ts b/src/core/utils/crypto/__fixtures__/index.ts new file mode 100644 index 00000000000000..12481963ff4f63 --- /dev/null +++ b/src/core/utils/crypto/__fixtures__/index.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; + +export const NO_CA_PATH = resolve(__dirname, './no_ca.p12'); +export const NO_CERT_PATH = resolve(__dirname, './no_cert.p12'); +export const NO_KEY_PATH = resolve(__dirname, './no_key.p12'); +export const TWO_CAS_PATH = resolve(__dirname, './two_cas.p12'); +export const TWO_KEYS_PATH = resolve(__dirname, './two_keys.p12'); diff --git a/src/core/utils/crypto/__fixtures__/no_ca.p12 b/src/core/utils/crypto/__fixtures__/no_ca.p12 new file mode 100644 index 00000000000000..1e6df9a0f71c54 Binary files /dev/null and b/src/core/utils/crypto/__fixtures__/no_ca.p12 differ diff --git a/src/core/utils/crypto/__fixtures__/no_cert.p12 b/src/core/utils/crypto/__fixtures__/no_cert.p12 new file mode 100644 index 00000000000000..8453fe878e7856 Binary files /dev/null and b/src/core/utils/crypto/__fixtures__/no_cert.p12 differ diff --git a/src/core/utils/crypto/__fixtures__/no_key.p12 b/src/core/utils/crypto/__fixtures__/no_key.p12 new file mode 100644 index 00000000000000..1bffb7a301b2e1 Binary files /dev/null and b/src/core/utils/crypto/__fixtures__/no_key.p12 differ diff --git a/src/core/utils/crypto/__fixtures__/two_cas.p12 b/src/core/utils/crypto/__fixtures__/two_cas.p12 new file mode 100644 index 00000000000000..25784a6fb9a948 Binary files /dev/null and b/src/core/utils/crypto/__fixtures__/two_cas.p12 differ diff --git a/src/core/utils/crypto/__fixtures__/two_keys.p12 b/src/core/utils/crypto/__fixtures__/two_keys.p12 new file mode 100644 index 00000000000000..c934b34901a936 Binary files /dev/null and b/src/core/utils/crypto/__fixtures__/two_keys.p12 differ diff --git a/webpackShims/jquery.js b/src/core/utils/crypto/index.ts similarity index 89% rename from webpackShims/jquery.js rename to src/core/utils/crypto/index.ts index da81dd18cf71e7..9a36682cc4ecb5 100644 --- a/webpackShims/jquery.js +++ b/src/core/utils/crypto/index.ts @@ -17,4 +17,4 @@ * under the License. */ -window.jQuery = window.$ = module.exports = require('../node_modules/jquery/dist/jquery'); +export { Pkcs12ReadResult, readPkcs12Keystore, readPkcs12Truststore } from './pkcs12'; diff --git a/src/core/utils/crypto/pkcs12.test.ts b/src/core/utils/crypto/pkcs12.test.ts new file mode 100644 index 00000000000000..c6c28697c4bccf --- /dev/null +++ b/src/core/utils/crypto/pkcs12.test.ts @@ -0,0 +1,205 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + CA_CERT_PATH, + ES_KEY_PATH, + ES_CERT_PATH, + ES_P12_PATH, + ES_P12_PASSWORD, + ES_EMPTYPASSWORD_P12_PATH, + ES_NOPASSWORD_P12_PATH, +} from '@kbn/dev-utils'; +import { NO_CA_PATH, NO_CERT_PATH, NO_KEY_PATH, TWO_CAS_PATH, TWO_KEYS_PATH } from './__fixtures__'; +import { readFileSync } from 'fs'; + +import { readPkcs12Keystore, Pkcs12ReadResult, readPkcs12Truststore } from '.'; + +const reformatPem = (pem: string) => { + // ensure consistency in line endings when comparing two PEM files + return pem.replace(/\r\n/g, '\n').trim(); +}; + +const readPem = (file: string) => { + const raw = readFileSync(file, 'utf8'); + // strip bag attributes that are included from a previous PKCS #12 export + const pem = raw.substr(raw.indexOf('-----BEGIN')); + return reformatPem(pem); +}; + +let pemCA: string; +let pemCert: string; +let pemKey: string; + +beforeAll(() => { + pemCA = readPem(CA_CERT_PATH); + pemCert = readPem(ES_CERT_PATH); + pemKey = readPem(ES_KEY_PATH); +}); + +describe('#readPkcs12Keystore', () => { + const expectKey = (pkcs12ReadResult: Pkcs12ReadResult) => { + const result = reformatPem(pkcs12ReadResult.key!); + expect(result).toEqual(pemKey); + }; + + const expectCert = (pkcs12ReadResult: Pkcs12ReadResult) => { + const result = reformatPem(pkcs12ReadResult.cert!); + expect(result).toEqual(pemCert); + }; + + const expectCA = (pkcs12ReadResult: Pkcs12ReadResult, ca = [pemCA]) => { + const result = pkcs12ReadResult.ca?.map(x => reformatPem(x)); + expect(result).toEqual(ca); + }; + + describe('Succeeds when the correct password is used', () => { + let pkcs12ReadResult: Pkcs12ReadResult; + + beforeAll(() => { + // this is expensive, just do it once + pkcs12ReadResult = readPkcs12Keystore(ES_P12_PATH, ES_P12_PASSWORD); + }); + + it('Extracts the PEM key', () => { + expectKey(pkcs12ReadResult); + }); + + it('Extracts the PEM instance certificate', () => { + expectCert(pkcs12ReadResult); + }); + + it('Extracts the PEM CA certificate', () => { + expectCA(pkcs12ReadResult); + }); + }); + + describe('Succeeds on a key store with an empty password', () => { + let pkcs12ReadResult: Pkcs12ReadResult; + + beforeAll(() => { + // this is expensive, just do it once + pkcs12ReadResult = readPkcs12Keystore(ES_EMPTYPASSWORD_P12_PATH, ''); + }); + + it('Extracts the PEM key', () => { + expectKey(pkcs12ReadResult); + }); + + it('Extracts the PEM instance certificate', () => { + expectCert(pkcs12ReadResult); + }); + + it('Extracts the PEM CA certificate', () => { + expectCA(pkcs12ReadResult); + }); + }); + + describe('Succeeds on a key store with no password', () => { + let pkcs12ReadResult: Pkcs12ReadResult; + + beforeAll(() => { + // this is expensive, just do it once + pkcs12ReadResult = readPkcs12Keystore(ES_NOPASSWORD_P12_PATH); + }); + + it('Extracts the PEM key', () => { + expectKey(pkcs12ReadResult); + }); + + it('Extracts the PEM instance certificate', () => { + expectCert(pkcs12ReadResult); + }); + + it('Extracts the PEM CA certificate', () => { + expectCA(pkcs12ReadResult); + }); + }); + + describe('Handles other key store permutations', () => { + it('Succeeds with no key', () => { + const pkcs12ReadResult = readPkcs12Keystore(NO_KEY_PATH, ''); + expectCA(pkcs12ReadResult, [pemCert, pemCA]); + expect(pkcs12ReadResult.cert).toBeUndefined(); + expect(pkcs12ReadResult.key).toBeUndefined(); + }); + + it('Succeeds with no instance certificate', () => { + const pkcs12ReadResult = readPkcs12Keystore(NO_CERT_PATH, ''); + expectCA(pkcs12ReadResult); + expect(pkcs12ReadResult.cert).toBeUndefined(); + expectKey(pkcs12ReadResult); + }); + + it('Succeeds with no CA certificate', () => { + const pkcs12ReadResult = readPkcs12Keystore(NO_CA_PATH, ''); + expect(pkcs12ReadResult.ca).toBeUndefined(); + expectCert(pkcs12ReadResult); + expectKey(pkcs12ReadResult); + }); + + it('Succeeds with two CA certificates', () => { + const pkcs12ReadResult = readPkcs12Keystore(TWO_CAS_PATH, ''); + expectCA(pkcs12ReadResult, [pemCA, pemCA]); + expectCert(pkcs12ReadResult); + expectKey(pkcs12ReadResult); + }); + }); + + describe('Throws errors', () => { + const expectError = (password?: string) => { + expect(() => readPkcs12Keystore(ES_P12_PATH, password)).toThrowError( + 'PKCS#12 MAC could not be verified. Invalid password?' + ); + }; + + it('When an invalid password is used (incorrect)', () => { + expectError('incorrect'); + }); + + it('When an invalid password is used (empty)', () => { + expectError(''); + }); + + it('When an invalid password is used (undefined)', () => { + expectError(); + }); + + it('When an invalid file path is used', () => { + const path = 'invalid-filepath'; + expect(() => readPkcs12Keystore(path)).toThrowError( + `ENOENT: no such file or directory, open '${path}'` + ); + }); + + it('When two keys are present', () => { + expect(() => readPkcs12Keystore(TWO_KEYS_PATH, '')).toThrowError( + 'Keystore contains multiple private keys.' + ); + }); + }); +}); + +describe('#readPkcs12Truststore', () => { + it('reads all certificates into one CA array and discards any certificates that have keys', () => { + const ca = readPkcs12Truststore(ES_P12_PATH, ES_P12_PASSWORD); + const result = ca?.map(x => reformatPem(x)); + expect(result).toEqual([pemCA]); + }); +}); diff --git a/src/core/utils/crypto/pkcs12.ts b/src/core/utils/crypto/pkcs12.ts new file mode 100644 index 00000000000000..74f22bb685c2ff --- /dev/null +++ b/src/core/utils/crypto/pkcs12.ts @@ -0,0 +1,179 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { util, asn1, pkcs12, pki } from 'node-forge'; +import { readFileSync } from 'fs'; + +export interface Pkcs12ReadResult { + ca?: string[]; + cert?: string; + key?: string; +} + +/** + * Reads a private key and certificate chain from a PKCS12 key store. + * + * @remarks + * The PKCS12 key store may contain the following: + * - 0 or more certificates contained in a `certBag` (OID + * 1.2.840.113549.1.12.10.1.3); if a certificate has an associated + * private key it is treated as an instance certificate, otherwise it is + * treated as a CA certificate + * - 0 or 1 private keys contained in a `keyBag` (OID + * 1.2.840.113549.1.12.10.1.1) or a `pkcs8ShroudedKeyBag` (OID + * 1.2.840.113549.1.12.10.1.2) + * + * Any other PKCS12 bags are ignored. + * + * @privateRemarks + * This intentionally does not allow for a separate key store password and + * private key password. In conventional implementations, these two values + * are expected to be identical, so we do not support other configurations. + * + * @param path The file path of the PKCS12 key store + * @param password The optional password of the key store and private key; + * if there is no password, this may be an empty string or `undefined`, + * depending on how the key store was generated. + * @returns the parsed private key and certificate(s) in PEM format + */ +export const readPkcs12Keystore = (path: string, password?: string): Pkcs12ReadResult => { + const p12base64 = readFileSync(path, 'base64'); + const p12Der = util.decode64(p12base64); + const p12Asn1 = asn1.fromDer(p12Der); + const p12 = pkcs12.pkcs12FromAsn1(p12Asn1, password); + const keyObj = getKey(p12); + const { ca, cert } = getCerts(p12, keyObj?.publicKeyData); + return { ca, cert, key: keyObj?.key }; +}; + +/** + * Reads a certificate chain from a PKCS12 trust store. + * + * @remarks + * The PKCS12 trust store may contain the following: + * - 0 or more certificates contained in a `certBag` (OID + * 1.2.840.113549.1.12.10.1.3); all are treated as CA certificates + * + * Any other PKCS12 bags are ignored. + * + * @param path The file path of the PKCS12 trust store + * @param password The optional password of the trust store; if there is + * no password, this may be an empty string or `undefined`, depending on + * how the trust store was generated. + * @returns the parsed certificate(s) in PEM format + */ +export const readPkcs12Truststore = (path: string, password?: string): string[] | undefined => { + const p12base64 = readFileSync(path, 'base64'); + const p12Der = util.decode64(p12base64); + const p12Asn1 = asn1.fromDer(p12Der); + const p12 = pkcs12.pkcs12FromAsn1(p12Asn1, password); + const keyObj = getKey(p12); + const { ca } = getCerts(p12, keyObj?.publicKeyData); + return ca; +}; + +// jsbn.BigInteger as described in type definition is wrong, it doesn't include `compareTo` +interface BigInteger { + data: number[]; + t: number; + s: number; + toString(): string; + compareTo(bn: BigInteger): number; +} + +interface PublicKeyData { + n: BigInteger; // modulus + e: BigInteger; // public exponent +} + +const doesPubKeyMatch = (a?: PublicKeyData, b?: PublicKeyData) => { + if (a && b) { + return a.n.compareTo(b.n) === 0 && a.e.compareTo(b.e) === 0; + } + return false; +}; + +const getCerts = (p12: pkcs12.Pkcs12Pfx, pubKey?: PublicKeyData) => { + // OID 1.2.840.113549.1.12.10.1.3 (certBag) + const bags = getBags(p12, pki.oids.certBag); + let ca; + let cert; + if (bags && bags.length) { + const certs = bags.map(convertCert).filter(x => x !== undefined); + cert = certs.find(x => doesPubKeyMatch(x!.publicKeyData, pubKey))?.cert; + ca = certs.filter(x => !doesPubKeyMatch(x!.publicKeyData, pubKey)).map(x => x!.cert); + if (ca.length === 0) { + ca = undefined; + } + } + + return { ca, cert }; +}; + +export const convertCert = (bag: pkcs12.Bag) => { + const cert = bag.cert; + if (cert) { + const pem = pki.certificateToPem(cert); + const key = cert.publicKey as pki.rsa.PublicKey; + const publicKeyData: PublicKeyData = { + n: key.n as BigInteger, + e: key.e as BigInteger, + }; + return { + cert: pem, + publicKeyData, + }; + } + return undefined; +}; + +const getKey = (p12: pkcs12.Pkcs12Pfx) => { + // OID 1.2.840.113549.1.12.10.1.1 (keyBag) || OID 1.2.840.113549.1.12.10.1.2 (pkcs8ShroudedKeyBag) + const bags = [ + ...(getBags(p12, pki.oids.keyBag) || []), + ...(getBags(p12, pki.oids.pkcs8ShroudedKeyBag) || []), + ]; + if (bags && bags.length) { + if (bags.length > 1) { + throw new Error(`Keystore contains multiple private keys.`); + } + const key = bags[0].key as pki.rsa.PrivateKey; + if (key) { + const pem = pki.privateKeyToPem(key); + const publicKeyData: PublicKeyData = { + n: key.n as BigInteger, + e: key.e as BigInteger, + }; + return { + key: pem, + publicKeyData, + }; + } + } + return undefined; +}; + +const getBags = (p12: pkcs12.Pkcs12Pfx, bagType: string) => { + const bagObj = p12.getBags({ bagType }); + const bags = bagObj[bagType]; + if (bags && bags.length) { + return bags; + } + return undefined; +}; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index b51cc4ef56410e..7c8ed481c0a7d0 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -19,10 +19,12 @@ export * from './assert_never'; export * from './context'; +export * from './crypto'; export * from './deep_freeze'; export * from './get'; export * from './map_to_object'; export * from './merge'; export * from './pick'; +export * from './promise'; export * from './url'; export * from './unset'; diff --git a/src/core/utils/promise.test.ts b/src/core/utils/promise.test.ts new file mode 100644 index 00000000000000..97d16c5158c7b8 --- /dev/null +++ b/src/core/utils/promise.test.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { withTimeout } from './promise'; + +const delay = (ms: number, resolveValue?: any) => + new Promise(resolve => setTimeout(resolve, ms, resolveValue)); + +describe('withTimeout', () => { + it('resolves with a promise value if resolved in given timeout', async () => { + await expect( + withTimeout({ + promise: delay(10, 'value'), + timeout: 200, + errorMessage: 'error-message', + }) + ).resolves.toBe('value'); + }); + + it('rejects with errorMessage if not resolved in given time', async () => { + await expect( + withTimeout({ + promise: delay(200, 'value'), + timeout: 10, + errorMessage: 'error-message', + }) + ).rejects.toMatchInlineSnapshot(`[Error: error-message]`); + + await expect( + withTimeout({ + promise: new Promise(i => i), + timeout: 10, + errorMessage: 'error-message', + }) + ).rejects.toMatchInlineSnapshot(`[Error: error-message]`); + }); + + it('does not swallow promise error', async () => { + await expect( + withTimeout({ + promise: Promise.reject(new Error('from-promise')), + timeout: 10, + errorMessage: 'error-message', + }) + ).rejects.toMatchInlineSnapshot(`[Error: from-promise]`); + }); +}); diff --git a/src/core/utils/promise.ts b/src/core/utils/promise.ts new file mode 100644 index 00000000000000..ae1db9c54eda57 --- /dev/null +++ b/src/core/utils/promise.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function withTimeout({ + promise, + timeout, + errorMessage, +}: { + promise: Promise; + timeout: number; + errorMessage: string; +}) { + return Promise.race([ + promise, + new Promise((resolve, reject) => setTimeout(() => reject(new Error(errorMessage)), timeout)), + ]) as Promise; +} diff --git a/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js b/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js index 27335b35374ba3..19d74bcf89e302 100644 --- a/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js +++ b/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js @@ -48,6 +48,8 @@ export const CleanClientModulesOnDLLTask = { ]; const discoveredLegacyCorePluginEntries = await globby([ `${baseDir}/src/legacy/core_plugins/*/index.js`, + // Small exception to load dynamically discovered functions for timelion plugin + `${baseDir}/src/legacy/core_plugins/timelion/server/*_functions/**/*.js`, `!${baseDir}/src/legacy/core_plugins/**/public`, ]); const discoveredPluginEntries = await globby([ diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index d1734e836d9831..31a5a4c13a4be2 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -34,6 +34,10 @@ kibana_vars=( elasticsearch.ssl.certificateAuthorities elasticsearch.ssl.key elasticsearch.ssl.keyPassphrase + elasticsearch.ssl.keystore.path + elasticsearch.ssl.keystore.password + elasticsearch.ssl.truststore.path + elasticsearch.ssl.truststore.password elasticsearch.ssl.verificationMode elasticsearch.startupTimeout elasticsearch.username @@ -73,6 +77,10 @@ kibana_vars=( server.ssl.enabled server.ssl.key server.ssl.keyPassphrase + server.ssl.keystore.path + server.ssl.keystore.password + server.ssl.truststore.path + server.ssl.truststore.password server.ssl.redirectHttpFromPort server.ssl.supportedProtocols server.xsrf.whitelist diff --git a/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js b/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js index 2942e76f276281..ec7d256975b27e 100644 --- a/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js +++ b/src/legacy/core_plugins/console/server/__tests__/elasticsearch_proxy_config.js @@ -19,14 +19,10 @@ import expect from '@kbn/expect'; import moment from 'moment'; -import fs from 'fs'; -import { promisify } from 'bluebird'; import { getElasticsearchProxyConfig } from '../elasticsearch_proxy_config'; import https from 'https'; import http from 'http'; -const readFileAsync = promisify(fs.readFile, fs); - const getDefaultElasticsearchConfig = () => { return { hosts: ['http://localhost:9200', 'http://192.168.1.1:1234'], @@ -124,10 +120,10 @@ describe('plugins/console', function() { it(`sets ca when certificateAuthorities are specified`, function() { const { agent } = getElasticsearchProxyConfig({ ...config, - ssl: { ...config.ssl, certificateAuthorities: [__dirname + '/fixtures/ca.crt'] }, + ssl: { ...config.ssl, certificateAuthorities: ['content-of-some-path'] }, }); - expect(agent.options.ca).to.contain('test ca certificate\n'); + expect(agent.options.ca).to.contain('content-of-some-path'); }); describe('when alwaysPresentCertificate is false', () => { @@ -137,8 +133,8 @@ describe('plugins/console', function() { ssl: { ...config.ssl, alwaysPresentCertificate: false, - certificate: __dirname + '/fixtures/cert.crt', - key: __dirname + '/fixtures/cert.key', + certificate: 'content-of-some-path', + key: 'content-of-another-path', }, }); @@ -152,8 +148,8 @@ describe('plugins/console', function() { ssl: { ...config.ssl, alwaysPresentCertificate: false, - certificate: __dirname + '/fixtures/cert.crt', - key: __dirname + '/fixtures/cert.key', + certificate: 'content-of-some-path', + key: 'content-of-another-path', keyPassphrase: 'secret', }, }); @@ -163,22 +159,19 @@ describe('plugins/console', function() { }); describe('when alwaysPresentCertificate is true', () => { - it(`sets cert and key when certificate and key paths are specified`, async function() { - const certificatePath = __dirname + '/fixtures/cert.crt'; - const keyPath = __dirname + '/fixtures/cert.key'; - + it(`sets cert and key when certificate and key are specified`, async function() { const { agent } = getElasticsearchProxyConfig({ ...config, ssl: { ...config.ssl, alwaysPresentCertificate: true, - certificate: certificatePath, - key: keyPath, + certificate: 'content-of-some-path', + key: 'content-of-another-path', }, }); - expect(agent.options.cert).to.be(await readFileAsync(certificatePath, 'utf8')); - expect(agent.options.key).to.be(await readFileAsync(keyPath, 'utf8')); + expect(agent.options.cert).to.be('content-of-some-path'); + expect(agent.options.key).to.be('content-of-another-path'); }); it(`sets passphrase when certificate, key and keyPassphrase are specified`, function() { @@ -187,8 +180,8 @@ describe('plugins/console', function() { ssl: { ...config.ssl, alwaysPresentCertificate: true, - certificate: __dirname + '/fixtures/cert.crt', - key: __dirname + '/fixtures/cert.key', + certificate: 'content-of-some-path', + key: 'content-of-another-path', keyPassphrase: 'secret', }, }); @@ -197,13 +190,12 @@ describe('plugins/console', function() { }); it(`doesn't set cert when only certificate path is specified`, async function() { - const certificatePath = __dirname + '/fixtures/cert.crt'; const { agent } = getElasticsearchProxyConfig({ ...config, ssl: { ...config.ssl, alwaysPresentCertificate: true, - certificate: certificatePath, + certificate: 'content-of-some-path', key: undefined, }, }); @@ -213,14 +205,13 @@ describe('plugins/console', function() { }); it(`doesn't set key when only key path is specified`, async function() { - const keyPath = __dirname + '/fixtures/cert.key'; const { agent } = getElasticsearchProxyConfig({ ...config, ssl: { ...config.ssl, alwaysPresentCertificate: true, certificate: undefined, - key: keyPath, + key: 'content-of-some-path', }, }); diff --git a/src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt b/src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt deleted file mode 100644 index 075fdd038dafff..00000000000000 --- a/src/legacy/core_plugins/console/server/__tests__/fixtures/ca.crt +++ /dev/null @@ -1 +0,0 @@ -test ca certificate diff --git a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt b/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt deleted file mode 100644 index 360cdfaaaa5a96..00000000000000 --- a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.crt +++ /dev/null @@ -1 +0,0 @@ -test certificate diff --git a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key b/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key deleted file mode 100644 index 04d3bfef24188d..00000000000000 --- a/src/legacy/core_plugins/console/server/__tests__/fixtures/cert.key +++ /dev/null @@ -1 +0,0 @@ -test key diff --git a/src/legacy/core_plugins/console/server/__tests__/proxy_config.js b/src/legacy/core_plugins/console/server/__tests__/proxy_config.js index 821fb8bef64d58..2a221aa2611677 100644 --- a/src/legacy/core_plugins/console/server/__tests__/proxy_config.js +++ b/src/legacy/core_plugins/console/server/__tests__/proxy_config.js @@ -21,7 +21,6 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; -import fs from 'fs'; import https, { Agent as HttpsAgent } from 'https'; import { parse as parseUrl } from 'url'; @@ -36,14 +35,6 @@ const parsedGoogle = parseUrl('https://google.com/search'); const parsedLocalEs = parseUrl('https://localhost:5601/search'); describe('ProxyConfig', function() { - beforeEach(function() { - sinon.stub(fs, 'readFileSync').callsFake(path => ({ path })); - }); - - afterEach(function() { - fs.readFileSync.restore(); - }); - describe('constructor', function() { beforeEach(function() { sinon.stub(https, 'Agent'); @@ -56,7 +47,7 @@ describe('ProxyConfig', function() { it('uses ca to create sslAgent', function() { const config = new ProxyConfig({ ssl: { - ca: ['path/to/ca'], + ca: ['content-of-some-path'], }, }); @@ -64,7 +55,7 @@ describe('ProxyConfig', function() { sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; expect(sslAgentOpts).to.eql({ - ca: [{ path: 'path/to/ca' }], + ca: ['content-of-some-path'], cert: undefined, key: undefined, rejectUnauthorized: true, @@ -74,8 +65,8 @@ describe('ProxyConfig', function() { it('uses cert, and key to create sslAgent', function() { const config = new ProxyConfig({ ssl: { - cert: 'path/to/cert', - key: 'path/to/key', + cert: 'content-of-some-path', + key: 'content-of-another-path', }, }); @@ -84,8 +75,8 @@ describe('ProxyConfig', function() { const sslAgentOpts = https.Agent.firstCall.args[0]; expect(sslAgentOpts).to.eql({ ca: undefined, - cert: { path: 'path/to/cert' }, - key: { path: 'path/to/key' }, + cert: 'content-of-some-path', + key: 'content-of-another-path', rejectUnauthorized: true, }); }); @@ -93,9 +84,9 @@ describe('ProxyConfig', function() { it('uses ca, cert, and key to create sslAgent', function() { const config = new ProxyConfig({ ssl: { - ca: ['path/to/ca'], - cert: 'path/to/cert', - key: 'path/to/key', + ca: ['content-of-some-path'], + cert: 'content-of-another-path', + key: 'content-of-yet-another-path', rejectUnauthorized: true, }, }); @@ -104,9 +95,9 @@ describe('ProxyConfig', function() { sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; expect(sslAgentOpts).to.eql({ - ca: [{ path: 'path/to/ca' }], - cert: { path: 'path/to/cert' }, - key: { path: 'path/to/key' }, + ca: ['content-of-some-path'], + cert: 'content-of-another-path', + key: 'content-of-yet-another-path', rejectUnauthorized: true, }); }); diff --git a/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts b/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts index 9e7c814aa57a46..5f44a524e9cc8d 100644 --- a/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts +++ b/src/legacy/core_plugins/console/server/elasticsearch_proxy_config.ts @@ -18,13 +18,10 @@ */ import _ from 'lodash'; -import { readFileSync } from 'fs'; import http from 'http'; import https from 'https'; import url from 'url'; -const readFile = (file: string) => readFileSync(file, 'utf8'); - const createAgent = (legacyConfig: any) => { const target = url.parse(_.head(legacyConfig.hosts)); if (!/^https/.test(target.protocol || '')) return new http.Agent(); @@ -49,22 +46,12 @@ const createAgent = (legacyConfig: any) => { throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); } - if ( - legacyConfig.ssl && - Array.isArray(legacyConfig.ssl.certificateAuthorities) && - legacyConfig.ssl.certificateAuthorities.length > 0 - ) { - agentOptions.ca = legacyConfig.ssl.certificateAuthorities.map(readFile); - } + agentOptions.ca = legacyConfig.ssl?.certificateAuthorities; - if ( - legacyConfig.ssl && - legacyConfig.ssl.alwaysPresentCertificate && - legacyConfig.ssl.certificate && - legacyConfig.ssl.key - ) { - agentOptions.cert = readFile(legacyConfig.ssl.certificate); - agentOptions.key = readFile(legacyConfig.ssl.key); + const ignoreCertAndKey = !legacyConfig.ssl?.alwaysPresentCertificate; + if (!ignoreCertAndKey && legacyConfig.ssl?.certificate && legacyConfig.ssl?.key) { + agentOptions.cert = legacyConfig.ssl.certificate; + agentOptions.key = legacyConfig.ssl.key; agentOptions.passphrase = legacyConfig.ssl.keyPassphrase; } diff --git a/src/legacy/core_plugins/console/server/proxy_config.js b/src/legacy/core_plugins/console/server/proxy_config.js index 44a2769a46eb31..b8c1b11f9a0d35 100644 --- a/src/legacy/core_plugins/console/server/proxy_config.js +++ b/src/legacy/core_plugins/console/server/proxy_config.js @@ -20,7 +20,6 @@ import { values } from 'lodash'; import { format as formatUrl } from 'url'; import { Agent as HttpsAgent } from 'https'; -import { readFileSync } from 'fs'; import { WildcardMatcher } from './wildcard_matcher'; @@ -63,9 +62,9 @@ export class ProxyConfig { this.verifySsl = ssl.verify; const sslAgentOpts = { - ca: ssl.ca && ssl.ca.map(ca => readFileSync(ca)), - cert: ssl.cert && readFileSync(ssl.cert), - key: ssl.key && readFileSync(ssl.key), + ca: ssl.ca, + cert: ssl.cert, + key: ssl.key, }; if (values(sslAgentOpts).filter(Boolean).length) { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx index c2f716ff6c45a7..b83ac5b4a77955 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx @@ -19,6 +19,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; import moment from 'moment-timezone'; +import { unitOfTime } from 'moment'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import lightEuiTheme from '@elastic/eui/dist/eui_theme_light.json'; @@ -53,6 +54,58 @@ interface DiscoverHistogramState { chartsTheme: EuiChartThemeType['theme']; } +function findIntervalFromDuration( + dateValue: number, + esValue: number, + esUnit: unitOfTime.Base, + timeZone: string +) { + const date = moment.tz(dateValue, timeZone); + const startOfDate = moment.tz(date, timeZone).startOf(esUnit); + const endOfDate = moment + .tz(date, timeZone) + .startOf(esUnit) + .add(esValue, esUnit); + return endOfDate.valueOf() - startOfDate.valueOf(); +} + +function getIntervalInMs( + value: number, + esValue: number, + esUnit: unitOfTime.Base, + timeZone: string +): number { + switch (esUnit) { + case 's': + return 1000 * esValue; + case 'ms': + return 1 * esValue; + default: + return findIntervalFromDuration(value, esValue, esUnit, timeZone); + } +} + +export function findMinInterval( + xValues: number[], + esValue: number, + esUnit: string, + timeZone: string +): number { + return xValues.reduce((minInterval, currentXvalue, index) => { + let currentDiff = minInterval; + if (index > 0) { + currentDiff = Math.abs(xValues[index - 1] - currentXvalue); + } + const singleUnitInterval = getIntervalInMs( + currentXvalue, + esValue, + esUnit as unitOfTime.Base, + timeZone + ); + return Math.min(minInterval, singleUnitInterval, currentDiff); + }, Number.MAX_SAFE_INTEGER); +} + export class DiscoverHistogram extends Component { public static propTypes = { chartData: PropTypes.object, @@ -154,7 +207,7 @@ export class DiscoverHistogram extends Component { if (!_.isEqual(newQuery, oldQuery)) { const query = migrateLegacyQuery(newQuery); - $scope.updateQueryAndFetch({ query }); + if (!_.isEqual(query, newQuery)) { + $scope.updateQueryAndFetch({ query }); + } } }); @@ -817,6 +819,7 @@ function discoverController( title: i18n.translate('kbn.discover.errorLoadingData', { defaultMessage: 'Error loading data', }), + toastMessage: error.shortMessage, }); } }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index a40d9731a04f5f..d3bf3696c08a5c 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -91,7 +91,7 @@ export function useEsDocSearch({ useEffect(() => { requestData(); - }, []); + }); return [status, hit, indexPattern]; } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx index d5f6b63d121992..2910ff2825fe7d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_field_search.tsx @@ -67,11 +67,6 @@ export interface Props { * Additionally there's a button displayed that allows the user to show/hide more filter fields */ export function DiscoverFieldSearch({ onChange, value, types }: Props) { - if (typeof value !== 'string') { - // at initial rendering value is undefined (angular related), this catches the warning - // should be removed once all is react - return null; - } const searchPlaceholder = i18n.translate('kbn.discover.fieldChooser.searchPlaceHolder', { defaultMessage: 'Search field names', }); @@ -99,6 +94,12 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { missing: true, }); + if (typeof value !== 'string') { + // at initial rendering value is undefined (angular related), this catches the warning + // should be removed once all is react + return null; + } + const filterBtnAriaLabel = isPopoverOpen ? i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { defaultMessage: 'Hide field filter settings', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx index 37338decce2c29..a4e8ee2ca3d8aa 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern.tsx @@ -45,19 +45,19 @@ export function DiscoverIndexPattern({ selectedIndexPattern, setIndexPattern, }: DiscoverIndexPatternProps) { - if (!indexPatternList || indexPatternList.length === 0 || !selectedIndexPattern) { - // just in case, shouldn't happen - return null; - } - const options: IndexPatternRef[] = indexPatternList.map(entity => ({ + const options: IndexPatternRef[] = (indexPatternList || []).map(entity => ({ id: entity.id, title: entity.attributes!.title, })); + const { id: selectedId, attributes } = selectedIndexPattern || {}; const [selected, setSelected] = useState({ - id: selectedIndexPattern.id, - title: selectedIndexPattern.attributes!.title, + id: selectedId, + title: attributes?.title || '', }); + if (!selectedId) { + return null; + } return (

diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.tsx similarity index 65% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.ts rename to src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.tsx index 8bbeac086f093d..d6527b0d7beedf 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/field_chooser/discover_index_pattern_directive.tsx @@ -16,11 +16,23 @@ * specific language governing permissions and limitations * under the License. */ +import React from 'react'; import { wrapInI18nContext } from '../../../kibana_services'; -import { DiscoverIndexPattern } from './discover_index_pattern'; +import { DiscoverIndexPattern, DiscoverIndexPatternProps } from './discover_index_pattern'; + +/** + * At initial rendering the angular directive the selectedIndexPattern prop is undefined + * This wrapper catches this, had to be introduced to satisfy eslint + */ +export function DiscoverIndexPatternWrapper(props: DiscoverIndexPatternProps) { + if (!props.selectedIndexPattern || !Array.isArray(props.indexPatternList)) { + return null; + } + return ; +} export function createIndexPatternSelectDirective(reactDirective: any) { - return reactDirective(wrapInI18nContext(DiscoverIndexPattern), [ + return reactDirective(wrapInI18nContext(DiscoverIndexPatternWrapper), [ ['indexPatternList', { watchDepth: 'reference' }], ['selectedIndexPattern', { watchDepth: 'reference' }], ['setIndexPattern', { watchDepth: 'reference' }], diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg new file mode 100644 index 00000000000000..5a1d6e9a52f170 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts index cd3e0d2fd9f89c..c09995caab6690 100644 --- a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts +++ b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts @@ -68,7 +68,7 @@ export class LocalApplicationService { isUnmounted = true; }); (async () => { - const params = { element, appBasePath: '' }; + const params = { element, appBasePath: '', onAppLeave: () => undefined }; unmountHandler = isAppMountDeprecated(app.mount) ? await app.mount({ core: npStart.core }, params) : await app.mount(params); diff --git a/src/legacy/core_plugins/kibana/public/management/index.js b/src/legacy/core_plugins/kibana/public/management/index.js index 5323fb2dac2d2f..d62770956b88ef 100644 --- a/src/legacy/core_plugins/kibana/public/management/index.js +++ b/src/legacy/core_plugins/kibana/public/management/index.js @@ -28,7 +28,8 @@ import { I18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; import appTemplate from './app.html'; import landingTemplate from './landing.html'; -import { management, SidebarNav, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +import { ManagementSidebarNav } from '../../../../../plugins/management/public'; import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory, @@ -42,6 +43,7 @@ import { EuiIcon, EuiHorizontalRule, } from '@elastic/eui'; +import { npStart } from 'ui/new_platform'; const SIDENAV_ID = 'management-sidenav'; const LANDING_ID = 'management-landing'; @@ -102,7 +104,7 @@ export function updateLandingPage(version) { ); } -export function updateSidebar(items, id) { +export function updateSidebar(legacySections, id) { const node = document.getElementById(SIDENAV_ID); if (!node) { return; @@ -110,7 +112,12 @@ export function updateSidebar(items, id) { render( - + , node ); diff --git a/src/legacy/core_plugins/kibana/server/tutorials/register.js b/src/legacy/core_plugins/kibana/server/tutorials/register.js index ecc2d1df8c3883..53ec16c1ca593b 100644 --- a/src/legacy/core_plugins/kibana/server/tutorials/register.js +++ b/src/legacy/core_plugins/kibana/server/tutorials/register.js @@ -83,6 +83,7 @@ import { awsLogsSpecProvider } from './aws_logs'; import { activemqLogsSpecProvider } from './activemq_logs'; import { activemqMetricsSpecProvider } from './activemq_metrics'; import { azureMetricsSpecProvider } from './azure_metrics'; +import { stanMetricsSpecProvider } from './stan_metrics'; import { envoyproxyMetricsSpecProvider } from './envoyproxy_metrics'; export function registerTutorials(server) { @@ -155,5 +156,6 @@ export function registerTutorials(server) { server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqLogsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(azureMetricsSpecProvider); + server.newPlatform.setup.plugins.home.tutorials.registerTutorial(stanMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(envoyproxyMetricsSpecProvider); } diff --git a/src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js b/src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js new file mode 100644 index 00000000000000..3f5817ce2890b3 --- /dev/null +++ b/src/legacy/core_plugins/kibana/server/tutorials/stan_metrics/index.js @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../../../common/tutorials/metricbeat_instructions'; + +export function stanMetricsSpecProvider(context) { + const moduleName = 'stan'; + return { + id: 'stanMetrics', + name: i18n.translate('kbn.server.tutorials.stanMetrics.nameTitle', { + defaultMessage: 'STAN metrics', + }), + category: TUTORIAL_CATEGORY.METRICS, + shortDescription: i18n.translate('kbn.server.tutorials.stanMetrics.shortDescription', { + defaultMessage: 'Fetch monitoring metrics from the STAN server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.stanMetrics.longDescription', { + defaultMessage: + 'The `stan` Metricbeat module fetches monitoring metrics from STAN. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-stan.html', + }, + }), + euiIconType: '/plugins/kibana/home/tutorial_resources/logos/stan.svg', + artifacts: { + dashboards: [], + exportedFields: { + documentationUrl: '{config.docs.beats.metricbeat}/exported-fields-stan.html', + }, + }, + completionTimeMinutes: 10, + // previewImagePath: '/plugins/kibana/home/tutorial_resources/stan_metrics/screenshot.png', + onPrem: onPremInstructions(moduleName, null, null, null, context), + elasticCloud: cloudInstructions(moduleName), + onPremElasticCloud: onPremCloudInstructions(moduleName), + }; +} diff --git a/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx b/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx index a3ebda8b3df0cb..15c92f4617497a 100644 --- a/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx +++ b/src/legacy/core_plugins/tile_map/public/components/tile_map_options.tsx @@ -41,7 +41,7 @@ function TileMapOptions(props: TileMapOptionsProps) { if (!stateParams.mapType) { setValue('mapType', vis.type.editorConfig.collections.mapTypes[0]); } - }, []); + }, [setValue, stateParams.mapType, vis.type.editorConfig.collections.mapTypes]); return ( <> diff --git a/src/legacy/core_plugins/timelion/common/types.ts b/src/legacy/core_plugins/timelion/common/types.ts new file mode 100644 index 00000000000000..f7084948a14f73 --- /dev/null +++ b/src/legacy/core_plugins/timelion/common/types.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +type TimelionFunctionArgsTypes = 'seriesList' | 'number' | 'string' | 'boolean' | 'null'; + +interface TimelionFunctionArgsSuggestion { + name: string; + help: string; +} + +export interface TimelionFunctionArgs { + name: string; + help?: string; + multi?: boolean; + types: TimelionFunctionArgsTypes[]; + suggestions?: TimelionFunctionArgsSuggestion[]; +} + +export interface ITimelionFunction { + aliases: string[]; + args: TimelionFunctionArgs[]; + name: string; + help: string; + chainable: boolean; + extended: boolean; + isAlias: boolean; + argsByName: { + [key: string]: TimelionFunctionArgs[]; + }; +} diff --git a/src/legacy/core_plugins/timelion/public/components/_index.scss b/src/legacy/core_plugins/timelion/public/components/_index.scss new file mode 100644 index 00000000000000..f2458a367e1768 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/_index.scss @@ -0,0 +1 @@ +@import './timelion_expression_input'; diff --git a/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss b/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss new file mode 100644 index 00000000000000..b1c0b5514ff7ad --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/_timelion_expression_input.scss @@ -0,0 +1,18 @@ +.timExpressionInput { + flex: 1 1 auto; + display: flex; + flex-direction: column; + margin-top: $euiSize; +} + +.timExpressionInput__editor { + height: 100%; + padding-top: $euiSizeS; +} + +@include euiBreakpoint('xs', 's', 'm') { + .timExpressionInput__editor { + height: $euiSize * 15; + max-height: $euiSize * 15; + } +} diff --git a/webpackShims/moment.js b/src/legacy/core_plugins/timelion/public/components/index.ts similarity index 90% rename from webpackShims/moment.js rename to src/legacy/core_plugins/timelion/public/components/index.ts index 31476d18c9562a..8d7d32a3ba2627 100644 --- a/webpackShims/moment.js +++ b/src/legacy/core_plugins/timelion/public/components/index.ts @@ -17,4 +17,5 @@ * under the License. */ -module.exports = require('../node_modules/moment/min/moment-with-locales.min.js'); +export * from './timelion_expression_input'; +export * from './timelion_interval'; diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx new file mode 100644 index 00000000000000..c695d09ca822bd --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input.tsx @@ -0,0 +1,146 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useCallback, useRef, useMemo } from 'react'; +import { EuiFormLabel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; + +import { CodeEditor, useKibana } from '../../../../../plugins/kibana_react/public'; +import { suggest, getSuggestion } from './timelion_expression_input_helpers'; +import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; +import { getArgValueSuggestions } from '../services/arg_value_suggestions'; + +const LANGUAGE_ID = 'timelion_expression'; +monacoEditor.languages.register({ id: LANGUAGE_ID }); + +interface TimelionExpressionInputProps { + value: string; + setValue(value: string): void; +} + +function TimelionExpressionInput({ value, setValue }: TimelionExpressionInputProps) { + const functionList = useRef([]); + const kibana = useKibana(); + const argValueSuggestions = useMemo(getArgValueSuggestions, []); + + const provideCompletionItems = useCallback( + async (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => { + const text = model.getValue(); + const wordUntil = model.getWordUntilPosition(position); + const wordRange = new monacoEditor.Range( + position.lineNumber, + wordUntil.startColumn, + position.lineNumber, + wordUntil.endColumn + ); + + const suggestions = await suggest( + text, + functionList.current, + // it's important to offset the cursor position on 1 point left + // because of PEG parser starts the line with 0, but monaco with 1 + position.column - 1, + argValueSuggestions + ); + + return { + suggestions: suggestions + ? suggestions.list.map((s: ITimelionFunction | TimelionFunctionArgs) => + getSuggestion(s, suggestions.type, wordRange) + ) + : [], + }; + }, + [argValueSuggestions] + ); + + const provideHover = useCallback( + async (model: monacoEditor.editor.ITextModel, position: monacoEditor.Position) => { + const suggestions = await suggest( + model.getValue(), + functionList.current, + // it's important to offset the cursor position on 1 point left + // because of PEG parser starts the line with 0, but monaco with 1 + position.column - 1, + argValueSuggestions + ); + + return { + contents: suggestions + ? suggestions.list.map((s: ITimelionFunction | TimelionFunctionArgs) => ({ + value: s.help, + })) + : [], + }; + }, + [argValueSuggestions] + ); + + useEffect(() => { + if (kibana.services.http) { + kibana.services.http.get('../api/timelion/functions').then(data => { + functionList.current = data; + }); + } + }, [kibana.services.http]); + + return ( +
+ + + +
+ +
+
+ ); +} + +export { TimelionExpressionInput }; diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts new file mode 100644 index 00000000000000..fc90c276eeca2f --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_expression_input_helpers.ts @@ -0,0 +1,287 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get, startsWith } from 'lodash'; +import PEG from 'pegjs'; +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; + +// @ts-ignore +import grammar from 'raw-loader!../chain.peg'; + +import { i18n } from '@kbn/i18n'; +import { ITimelionFunction, TimelionFunctionArgs } from '../../common/types'; +import { ArgValueSuggestions, FunctionArg, Location } from '../services/arg_value_suggestions'; + +const Parser = PEG.generate(grammar); + +export enum SUGGESTION_TYPE { + ARGUMENTS = 'arguments', + ARGUMENT_VALUE = 'argument_value', + FUNCTIONS = 'functions', +} + +function inLocation(cursorPosition: number, location: Location) { + return cursorPosition >= location.min && cursorPosition <= location.max; +} + +function getArgumentsHelp( + functionHelp: ITimelionFunction | undefined, + functionArgs: FunctionArg[] = [] +) { + if (!functionHelp) { + return []; + } + + // Do not provide 'inputSeries' as argument suggestion for chainable functions + const argsHelp = functionHelp.chainable ? functionHelp.args.slice(1) : functionHelp.args.slice(0); + + // ignore arguments that are already provided in function declaration + const functionArgNames = functionArgs.map(arg => arg.name); + return argsHelp.filter(arg => !functionArgNames.includes(arg.name)); +} + +async function extractSuggestionsFromParsedResult( + result: ReturnType, + cursorPosition: number, + functionList: ITimelionFunction[], + argValueSuggestions: ArgValueSuggestions +) { + const activeFunc = result.functions.find(({ location }: { location: Location }) => + inLocation(cursorPosition, location) + ); + + if (!activeFunc) { + return; + } + + const functionHelp = functionList.find(({ name }) => name === activeFunc.function); + + if (!functionHelp) { + return; + } + + // return function suggestion when cursor is outside of parentheses + // location range includes '.', function name, and '('. + const openParen = activeFunc.location.min + activeFunc.function.length + 2; + if (cursorPosition < openParen) { + return { list: [functionHelp], type: SUGGESTION_TYPE.FUNCTIONS }; + } + + // return argument value suggestions when cursor is inside argument value + const activeArg = activeFunc.arguments.find((argument: FunctionArg) => { + return inLocation(cursorPosition, argument.location); + }); + if ( + activeArg && + activeArg.type === 'namedArg' && + inLocation(cursorPosition, activeArg.value.location) + ) { + const { function: functionName, arguments: functionArgs } = activeFunc; + + const { + name: argName, + value: { text: partialInput }, + } = activeArg; + + let valueSuggestions; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument( + functionName, + argName, + functionArgs, + partialInput + ); + } else { + const { suggestions: staticSuggestions } = + functionHelp.args.find(arg => arg.name === activeArg.name) || {}; + valueSuggestions = argValueSuggestions.getStaticSuggestionsForInput( + partialInput, + staticSuggestions + ); + } + return { + list: valueSuggestions, + type: SUGGESTION_TYPE.ARGUMENT_VALUE, + }; + } + + // return argument suggestions + const argsHelp = getArgumentsHelp(functionHelp, activeFunc.arguments); + const argumentSuggestions = argsHelp.filter(arg => { + if (get(activeArg, 'type') === 'namedArg') { + return startsWith(arg.name, activeArg.name); + } else if (activeArg) { + return startsWith(arg.name, activeArg.text); + } + return true; + }); + return { list: argumentSuggestions, type: SUGGESTION_TYPE.ARGUMENTS }; +} + +export async function suggest( + expression: string, + functionList: ITimelionFunction[], + cursorPosition: number, + argValueSuggestions: ArgValueSuggestions +) { + try { + const result = await Parser.parse(expression); + + return await extractSuggestionsFromParsedResult( + result, + cursorPosition, + functionList, + argValueSuggestions + ); + } catch (err) { + let message: any; + try { + // The grammar will throw an error containing a message if the expression is formatted + // correctly and is prepared to accept suggestions. If the expression is not formatted + // correctly the grammar will just throw a regular PEG SyntaxError, and this JSON.parse + // attempt will throw an error. + message = JSON.parse(err.message); + } catch (e) { + // The expression isn't correctly formatted, so JSON.parse threw an error. + return; + } + + switch (message.type) { + case 'incompleteFunction': { + let list; + if (message.function) { + // The user has start typing a function name, so we'll filter the list down to only + // possible matches. + list = functionList.filter(func => startsWith(func.name, message.function)); + } else { + // The user hasn't typed anything yet, so we'll just return the entire list. + list = functionList; + } + return { list, type: SUGGESTION_TYPE.FUNCTIONS }; + } + case 'incompleteArgument': { + const { currentFunction: functionName, currentArgs: functionArgs } = message; + const functionHelp = functionList.find(func => func.name === functionName); + return { + list: getArgumentsHelp(functionHelp, functionArgs), + type: SUGGESTION_TYPE.ARGUMENTS, + }; + } + case 'incompleteArgumentValue': { + const { name: argName, currentFunction: functionName, currentArgs: functionArgs } = message; + let valueSuggestions = []; + if (argValueSuggestions.hasDynamicSuggestionsForArgument(functionName, argName)) { + valueSuggestions = await argValueSuggestions.getDynamicSuggestionsForArgument( + functionName, + argName, + functionArgs + ); + } else { + const functionHelp = functionList.find(func => func.name === functionName); + if (functionHelp) { + const argHelp = functionHelp.args.find(arg => arg.name === argName); + if (argHelp && argHelp.suggestions) { + valueSuggestions = argHelp.suggestions; + } + } + } + return { + list: valueSuggestions, + type: SUGGESTION_TYPE.ARGUMENT_VALUE, + }; + } + } + } +} + +export function getSuggestion( + suggestion: ITimelionFunction | TimelionFunctionArgs, + type: SUGGESTION_TYPE, + range: monacoEditor.Range +): monacoEditor.languages.CompletionItem { + let kind: monacoEditor.languages.CompletionItemKind = + monacoEditor.languages.CompletionItemKind.Method; + let insertText: string = suggestion.name; + let insertTextRules: monacoEditor.languages.CompletionItem['insertTextRules']; + let detail: string = ''; + let command: monacoEditor.languages.CompletionItem['command']; + + switch (type) { + case SUGGESTION_TYPE.ARGUMENTS: + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Property; + insertText = `${insertText}=`; + detail = `${i18n.translate( + 'timelion.expressionSuggestions.argument.description.acceptsText', + { + defaultMessage: 'Accepts', + } + )}: ${(suggestion as TimelionFunctionArgs).types}`; + + break; + case SUGGESTION_TYPE.FUNCTIONS: + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Function; + insertText = `${insertText}($0)`; + insertTextRules = monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet; + detail = `(${ + (suggestion as ITimelionFunction).chainable + ? i18n.translate('timelion.expressionSuggestions.func.description.chainableHelpText', { + defaultMessage: 'Chainable', + }) + : i18n.translate('timelion.expressionSuggestions.func.description.dataSourceHelpText', { + defaultMessage: 'Data source', + }) + })`; + + break; + case SUGGESTION_TYPE.ARGUMENT_VALUE: + const param = suggestion.name.split(':'); + + if (param.length === 1 || param[1]) { + insertText = `${param.length === 1 ? insertText : param[1]},`; + } + + command = { + title: 'Trigger Suggestion Dialog', + id: 'editor.action.triggerSuggest', + }; + kind = monacoEditor.languages.CompletionItemKind.Property; + detail = suggestion.help || ''; + + break; + } + + return { + detail, + insertText, + insertTextRules, + kind, + label: suggestion.name, + documentation: suggestion.help, + command, + range, + }; +} diff --git a/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx b/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx new file mode 100644 index 00000000000000..6294e51e54788d --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/components/timelion_interval.tsx @@ -0,0 +1,144 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiFormRow, EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useValidation } from 'ui/vis/editors/default/controls/agg_utils'; +import { isValidEsInterval } from '../../../../core_plugins/data/common'; + +const intervalOptions = [ + { + label: i18n.translate('timelion.vis.interval.auto', { + defaultMessage: 'Auto', + }), + value: 'auto', + }, + { + label: i18n.translate('timelion.vis.interval.second', { + defaultMessage: '1 second', + }), + value: '1s', + }, + { + label: i18n.translate('timelion.vis.interval.minute', { + defaultMessage: '1 minute', + }), + value: '1m', + }, + { + label: i18n.translate('timelion.vis.interval.hour', { + defaultMessage: '1 hour', + }), + value: '1h', + }, + { + label: i18n.translate('timelion.vis.interval.day', { + defaultMessage: '1 day', + }), + value: '1d', + }, + { + label: i18n.translate('timelion.vis.interval.week', { + defaultMessage: '1 week', + }), + value: '1w', + }, + { + label: i18n.translate('timelion.vis.interval.month', { + defaultMessage: '1 month', + }), + value: '1M', + }, + { + label: i18n.translate('timelion.vis.interval.year', { + defaultMessage: '1 year', + }), + value: '1y', + }, +]; + +interface TimelionIntervalProps { + value: string; + setValue(value: string): void; + setValidity(valid: boolean): void; +} + +function TimelionInterval({ value, setValue, setValidity }: TimelionIntervalProps) { + const onCustomInterval = useCallback( + (customValue: string) => { + setValue(customValue.trim()); + }, + [setValue] + ); + + const onChange = useCallback( + (opts: Array>) => { + setValue((opts[0] && opts[0].value) || ''); + }, + [setValue] + ); + + const selectedOptions = useMemo( + () => [intervalOptions.find(op => op.value === value) || { label: value, value }], + [value] + ); + + const isValid = intervalOptions.some(int => int.value === value) || isValidEsInterval(value); + + useValidation(setValidity, isValid); + + return ( + + + + ); +} + +export { TimelionInterval }; diff --git a/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js b/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js index b90f5932b5b099..231330b898edb5 100644 --- a/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js +++ b/src/legacy/core_plugins/timelion/public/directives/__tests__/timelion_expression_input_helpers.js @@ -21,9 +21,15 @@ import expect from '@kbn/expect'; import PEG from 'pegjs'; import grammar from 'raw-loader!../../chain.peg'; import { SUGGESTION_TYPE, suggest } from '../timelion_expression_input_helpers'; -import { ArgValueSuggestionsProvider } from '../timelion_expression_suggestions/arg_value_suggestions'; +import { getArgValueSuggestions } from '../../services/arg_value_suggestions'; +import { setIndexPatterns, setSavedObjectsClient } from '../../services/plugin_services'; describe('Timelion expression suggestions', () => { + setIndexPatterns({}); + setSavedObjectsClient({}); + + const argValueSuggestions = getArgValueSuggestions(); + describe('getSuggestions', () => { const func1 = { name: 'func1', @@ -44,11 +50,6 @@ describe('Timelion expression suggestions', () => { }; const functionList = [func1, myFunc2]; let Parser; - const privateStub = () => { - return {}; - }; - const indexPatternsStub = {}; - const argValueSuggestions = ArgValueSuggestionsProvider(privateStub, indexPatternsStub); // eslint-disable-line new-cap beforeEach(function() { Parser = PEG.generate(grammar); }); diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js index 137dd6b82046dc..449c0489fea251 100644 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js +++ b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js @@ -52,11 +52,11 @@ import { insertAtLocation, } from './timelion_expression_input_helpers'; import { comboBoxKeyCodes } from '@elastic/eui'; -import { ArgValueSuggestionsProvider } from './timelion_expression_suggestions/arg_value_suggestions'; +import { getArgValueSuggestions } from '../services/arg_value_suggestions'; const Parser = PEG.generate(grammar); -export function TimelionExpInput($http, $timeout, Private) { +export function TimelionExpInput($http, $timeout) { return { restrict: 'E', scope: { @@ -68,7 +68,7 @@ export function TimelionExpInput($http, $timeout, Private) { replace: true, template: timelionExpressionInputTemplate, link: function(scope, elem) { - const argValueSuggestions = Private(ArgValueSuggestionsProvider); + const argValueSuggestions = getArgValueSuggestions(); const expressionInput = elem.find('[data-expression-input]'); const functionReference = {}; let suggestibleFunctionLocation = {}; diff --git a/src/legacy/core_plugins/timelion/public/index.scss b/src/legacy/core_plugins/timelion/public/index.scss index f6123f40521562..7ccc6c300bc40b 100644 --- a/src/legacy/core_plugins/timelion/public/index.scss +++ b/src/legacy/core_plugins/timelion/public/index.scss @@ -11,5 +11,6 @@ // timChart__legend-isLoading @import './app'; +@import './components/index'; @import './directives/index'; @import './vis/index'; diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts index d989a68d40eeb4..1cf6bb65cdc029 100644 --- a/src/legacy/core_plugins/timelion/public/legacy.ts +++ b/src/legacy/core_plugins/timelion/public/legacy.ts @@ -37,4 +37,4 @@ const setupPlugins: Readonly = { const pluginInstance = plugin({} as PluginInitializerContext); export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(npStart.core); +export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts b/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts index 04b27c4020ce3b..0bbda4bf3646fc 100644 --- a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts +++ b/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts @@ -35,6 +35,7 @@ const DEBOUNCE_DELAY = 50; export function timechartFn(dependencies: TimelionVisualizationDependencies) { const { $rootScope, $compile, uiSettings } = dependencies; + return function() { return { help: 'Draw a timeseries chart', diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts index ba8c25c20abeab..42f0ee3ad47258 100644 --- a/src/legacy/core_plugins/timelion/public/plugin.ts +++ b/src/legacy/core_plugins/timelion/public/plugin.ts @@ -26,12 +26,14 @@ import { } from 'kibana/public'; import { Plugin as ExpressionsPlugin } from 'src/plugins/expressions/public'; import { DataPublicPluginSetup, TimefilterContract } from 'src/plugins/data/public'; +import { PluginsStart } from 'ui/new_platform/new_platform'; import { VisualizationsSetup } from '../../visualizations/public/np_ready/public'; import { getTimelionVisualizationConfig } from './timelion_vis_fn'; import { getTimelionVisualization } from './vis'; import { getTimeChart } from './panels/timechart/timechart'; import { Panel } from './panels/panel'; import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; +import { setIndexPatterns, setSavedObjectsClient } from './services/plugin_services'; /** @internal */ export interface TimelionVisualizationDependencies extends LegacyDependenciesPluginSetup { @@ -85,12 +87,15 @@ export class TimelionPlugin implements Plugin, void> { dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); } - public start(core: CoreStart) { + public start(core: CoreStart, plugins: PluginsStart) { const timelionUiEnabled = core.injectedMetadata.getInjectedVar('timelionUiEnabled'); if (timelionUiEnabled === false) { core.chrome.navLinks.update('timelion', { hidden: true }); } + + setIndexPatterns(plugins.data.indexPatterns); + setSavedObjectsClient(core.savedObjects.client); } public stop(): void {} diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js b/src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts similarity index 72% rename from src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js rename to src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts index e698a69401a376..8d133de51f6d9a 100644 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_suggestions/arg_value_suggestions.js +++ b/src/legacy/core_plugins/timelion/public/services/arg_value_suggestions.ts @@ -17,33 +17,51 @@ * under the License. */ -import _ from 'lodash'; -import { npStart } from 'ui/new_platform'; +import { get } from 'lodash'; +import { TimelionFunctionArgs } from '../../common/types'; +import { getIndexPatterns, getSavedObjectsClient } from './plugin_services'; -export function ArgValueSuggestionsProvider() { - const { indexPatterns } = npStart.plugins.data; - const { client: savedObjectsClient } = npStart.core.savedObjects; +export interface Location { + min: number; + max: number; +} - async function getIndexPattern(functionArgs) { - const indexPatternArg = functionArgs.find(argument => { - return argument.name === 'index'; - }); +export interface FunctionArg { + function: string; + location: Location; + name: string; + text: string; + type: string; + value: { + location: Location; + text: string; + type: string; + value: string; + }; +} + +export function getArgValueSuggestions() { + const indexPatterns = getIndexPatterns(); + const savedObjectsClient = getSavedObjectsClient(); + + async function getIndexPattern(functionArgs: FunctionArg[]) { + const indexPatternArg = functionArgs.find(({ name }) => name === 'index'); if (!indexPatternArg) { // index argument not provided return; } - const indexPatternTitle = _.get(indexPatternArg, 'value.text'); + const indexPatternTitle = get(indexPatternArg, 'value.text'); - const resp = await savedObjectsClient.find({ + const { savedObjects } = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title'], search: `"${indexPatternTitle}"`, - search_fields: ['title'], + searchFields: ['title'], perPage: 10, }); - const indexPatternSavedObject = resp.savedObjects.find(savedObject => { - return savedObject.attributes.title === indexPatternTitle; - }); + const indexPatternSavedObject = savedObjects.find( + ({ attributes }) => attributes.title === indexPatternTitle + ); if (!indexPatternSavedObject) { // index argument does not match an index pattern return; @@ -52,7 +70,7 @@ export function ArgValueSuggestionsProvider() { return await indexPatterns.get(indexPatternSavedObject.id); } - function containsFieldName(partial, field) { + function containsFieldName(partial: string, field: { name: string }) { if (!partial) { return true; } @@ -63,13 +81,13 @@ export function ArgValueSuggestionsProvider() { // Could not put with function definition since functions are defined on server const customHandlers = { es: { - index: async function(partial) { + async index(partial: string) { const search = partial ? `${partial}*` : '*'; const resp = await savedObjectsClient.find({ type: 'index-pattern', fields: ['title', 'type'], search: `${search}`, - search_fields: ['title'], + searchFields: ['title'], perPage: 25, }); return resp.savedObjects @@ -78,7 +96,7 @@ export function ArgValueSuggestionsProvider() { return { name: savedObject.attributes.title }; }); }, - metric: async function(partial, functionArgs) { + async metric(partial: string, functionArgs: FunctionArg[]) { if (!partial || !partial.includes(':')) { return [ { name: 'avg:' }, @@ -109,7 +127,7 @@ export function ArgValueSuggestionsProvider() { return { name: `${valueSplit[0]}:${field.name}`, help: field.type }; }); }, - split: async function(partial, functionArgs) { + async split(partial: string, functionArgs: FunctionArg[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -127,7 +145,7 @@ export function ArgValueSuggestionsProvider() { return { name: field.name, help: field.type }; }); }, - timefield: async function(partial, functionArgs) { + async timefield(partial: string, functionArgs: FunctionArg[]) { const indexPattern = await getIndexPattern(functionArgs); if (!indexPattern) { return []; @@ -150,7 +168,10 @@ export function ArgValueSuggestionsProvider() { * @param {string} argName - user provided argument name * @return {boolean} true when dynamic suggestion handler provided for function argument */ - hasDynamicSuggestionsForArgument: (functionName, argName) => { + hasDynamicSuggestionsForArgument: ( + functionName: T, + argName: keyof typeof customHandlers[T] + ) => { return customHandlers[functionName] && customHandlers[functionName][argName]; }, @@ -161,12 +182,13 @@ export function ArgValueSuggestionsProvider() { * @param {string} partial - user provided argument value * @return {array} array of dynamic suggestions matching partial */ - getDynamicSuggestionsForArgument: async ( - functionName, - argName, - functionArgs, + getDynamicSuggestionsForArgument: async ( + functionName: T, + argName: keyof typeof customHandlers[T], + functionArgs: FunctionArg[], partialInput = '' ) => { + // @ts-ignore return await customHandlers[functionName][argName](partialInput, functionArgs); }, @@ -175,7 +197,10 @@ export function ArgValueSuggestionsProvider() { * @param {array} staticSuggestions - argument value suggestions * @return {array} array of static suggestions matching partial */ - getStaticSuggestionsForInput: (partialInput = '', staticSuggestions = []) => { + getStaticSuggestionsForInput: ( + partialInput = '', + staticSuggestions: TimelionFunctionArgs['suggestions'] = [] + ) => { if (partialInput) { return staticSuggestions.filter(suggestion => { return suggestion.name.includes(partialInput); @@ -186,3 +211,5 @@ export function ArgValueSuggestionsProvider() { }, }; } + +export type ArgValueSuggestions = ReturnType; diff --git a/src/legacy/core_plugins/timelion/public/services/plugin_services.ts b/src/legacy/core_plugins/timelion/public/services/plugin_services.ts new file mode 100644 index 00000000000000..5ba4ee5e479832 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/services/plugin_services.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPatternsContract } from 'src/plugins/data/public'; +import { SavedObjectsClientContract } from 'kibana/public'; +import { createGetterSetter } from '../../../../../plugins/kibana_utils/public'; + +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter( + 'IndexPatterns' +); + +export const [getSavedObjectsClient, setSavedObjectsClient] = createGetterSetter< + SavedObjectsClientContract +>('SavedObjectsClient'); diff --git a/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts b/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts index 474f464a550cd5..206f9f5d8368da 100644 --- a/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts +++ b/src/legacy/core_plugins/timelion/public/timelion_vis_fn.ts @@ -28,7 +28,7 @@ const name = 'timelion_vis'; interface Arguments { expression: string; - interval: any; + interval: string; } interface RenderValue { @@ -38,7 +38,7 @@ interface RenderValue { } type Context = KibanaContext | null; -type VisParams = Arguments; +export type VisParams = Arguments; type Return = Promise>; export const getTimelionVisualizationConfig = ( @@ -60,7 +60,7 @@ export const getTimelionVisualizationConfig = ( help: '', }, interval: { - types: ['string', 'null'], + types: ['string'], default: 'auto', help: '', }, diff --git a/src/legacy/core_plugins/timelion/public/vis/_index.scss b/src/legacy/core_plugins/timelion/public/vis/_index.scss index e44b6336d33c19..17a2018f7a56a8 100644 --- a/src/legacy/core_plugins/timelion/public/vis/_index.scss +++ b/src/legacy/core_plugins/timelion/public/vis/_index.scss @@ -1 +1,2 @@ @import './timelion_vis'; +@import './timelion_editor'; diff --git a/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss b/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss new file mode 100644 index 00000000000000..a9331930a86ffa --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/vis/_timelion_editor.scss @@ -0,0 +1,15 @@ +.visEditor--timelion { + vis-options-react-wrapper, + .visEditorSidebar__options, + .visEditorSidebar__timelionOptions { + flex: 1 1 auto; + display: flex; + flex-direction: column; + } + + .visEditor__sidebar { + @include euiBreakpoint('xs', 's', 'm') { + width: 100%; + } + } +} diff --git a/src/legacy/core_plugins/timelion/public/vis/index.ts b/src/legacy/core_plugins/timelion/public/vis/index.tsx similarity index 80% rename from src/legacy/core_plugins/timelion/public/vis/index.ts rename to src/legacy/core_plugins/timelion/public/vis/index.tsx index 7b82553a24e5b1..1edcb0a5ce71c0 100644 --- a/src/legacy/core_plugins/timelion/public/vis/index.ts +++ b/src/legacy/core_plugins/timelion/public/vis/index.tsx @@ -17,19 +17,24 @@ * under the License. */ +import React from 'react'; import { i18n } from '@kbn/i18n'; // @ts-ignore import { DefaultEditorSize } from 'ui/vis/editor_size'; +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { KibanaContextProvider } from '../../../../../plugins/kibana_react/public'; import { getTimelionRequestHandler } from './timelion_request_handler'; import visConfigTemplate from './timelion_vis.html'; -import editorConfigTemplate from './timelion_vis_params.html'; import { TimelionVisualizationDependencies } from '../plugin'; // @ts-ignore import { AngularVisController } from '../../../../ui/public/vis/vis_types/angular_vis_type'; +import { TimelionOptions } from './timelion_options'; +import { VisParams } from '../timelion_vis_fn'; export const TIMELION_VIS_NAME = 'timelion'; export function getTimelionVisualization(dependencies: TimelionVisualizationDependencies) { + const { http, uiSettings } = dependencies; const timelionRequestHandler = getTimelionRequestHandler(dependencies); // return the visType object, which kibana will use to display and configure new @@ -50,7 +55,11 @@ export function getTimelionVisualization(dependencies: TimelionVisualizationDepe template: visConfigTemplate, }, editorConfig: { - optionsTemplate: editorConfigTemplate, + optionsTemplate: (props: VisOptionsProps) => ( + + + + ), defaultSize: DefaultEditorSize.MEDIUM, }, requestHandler: timelionRequestHandler, diff --git a/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx b/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx new file mode 100644 index 00000000000000..527fcc3bc6ce87 --- /dev/null +++ b/src/legacy/core_plugins/timelion/public/vis/timelion_options.tsx @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useCallback } from 'react'; +import { EuiPanel } from '@elastic/eui'; + +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { VisParams } from '../timelion_vis_fn'; +import { TimelionInterval, TimelionExpressionInput } from '../components'; + +function TimelionOptions({ stateParams, setValue, setValidity }: VisOptionsProps) { + const setInterval = useCallback((value: VisParams['interval']) => setValue('interval', value), [ + setValue, + ]); + const setExpressionInput = useCallback( + (value: VisParams['expression']) => setValue('expression', value), + [setValue] + ); + + return ( + + + + + ); +} + +export { TimelionOptions }; diff --git a/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html b/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html deleted file mode 100644 index 9f2d2094fb1f7c..00000000000000 --- a/src/legacy/core_plugins/timelion/public/vis/timelion_vis_params.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
- -
- -
-
- -
-
- -
- - -
- -
diff --git a/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts b/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts index 6e32a4454e7074..798902aa133dee 100644 --- a/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts +++ b/src/legacy/core_plugins/timelion/server/lib/classes/timelion_function.d.ts @@ -17,6 +17,8 @@ * under the License. */ +import { TimelionFunctionArgs } from '../../../common/types'; + export interface TimelionFunctionInterface extends TimelionFunctionConfig { chainable: boolean; originalFn: Function; @@ -32,21 +34,6 @@ export interface TimelionFunctionConfig { args: TimelionFunctionArgs[]; } -export interface TimelionFunctionArgs { - name: string; - help?: string; - multi?: boolean; - types: TimelionFunctionArgsTypes[]; - suggestions?: TimelionFunctionArgsSuggestion[]; -} - -export type TimelionFunctionArgsTypes = 'seriesList' | 'number' | 'string' | 'boolean' | 'null'; - -export interface TimelionFunctionArgsSuggestion { - name: string; - help: string; -} - // eslint-disable-next-line import/no-default-export export default class TimelionFunction { constructor(name: string, config: TimelionFunctionConfig); diff --git a/src/legacy/core_plugins/timelion/server/types.ts b/src/legacy/core_plugins/timelion/server/types.ts index e612bc14a0daa5..a035d64f764f13 100644 --- a/src/legacy/core_plugins/timelion/server/types.ts +++ b/src/legacy/core_plugins/timelion/server/types.ts @@ -17,12 +17,5 @@ * under the License. */ -export { - TimelionFunctionInterface, - TimelionFunctionConfig, - TimelionFunctionArgs, - TimelionFunctionArgsSuggestion, - TimelionFunctionArgsTypes, -} from './lib/classes/timelion_function'; - +export { TimelionFunctionInterface, TimelionFunctionConfig } from './lib/classes/timelion_function'; export { TimelionRequestQuery } from './routes/run'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx index 53a7b1caef2a47..c70b6561c3101b 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx @@ -36,7 +36,7 @@ import { MarkdownVisParams } from './types'; function MarkdownOptions({ stateParams, setValue }: VisOptionsProps) { const onMarkdownUpdate = useCallback( (value: MarkdownVisParams['markdown']) => setValue('markdown', value), - [] + [setValue] ); return ( diff --git a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js index ca5ab209572819..0776dd13a98685 100644 --- a/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js +++ b/src/legacy/core_plugins/vis_type_metric/public/components/metric_vis_value.js @@ -21,13 +21,19 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { EuiKeyboardAccessible } from '@elastic/eui'; +import { EuiKeyboardAccessible, keyCodes } from '@elastic/eui'; class MetricVisValue extends Component { onClick = () => { this.props.onFilter(this.props.metric); }; + onKeyPress = e => { + if (e.keyCode === keyCodes.ENTER) { + this.onClick(); + } + }; + render() { const { fontSize, metric, onFilter, showLabel } = this.props; const hasFilter = !!onFilter; @@ -47,6 +53,7 @@ class MetricVisValue extends Component { className={containerClassName} style={{ backgroundColor: metric.bgColor }} onClick={hasFilter ? this.onClick : null} + onKeyPress={hasFilter ? this.onKeyPress : null} tabIndex={hasFilter ? 0 : null} role={hasFilter ? 'button' : null} > diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js index 07870379265c5d..83d7ca4084a202 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js @@ -74,7 +74,11 @@ export function KbnAggTable(config, RecursionHelper) { // escape each cell in each row const csvRows = rows.map(function(row) { return Object.entries(row).map(([k, v]) => { - return escape(formatted ? columns.find(c => c.id === k).formatter.convert(v) : v); + const column = columns.find(c => c.id === k); + if (formatted && column) { + return escape(column.formatter.convert(v)); + } + return escape(v); }); }); @@ -110,12 +114,16 @@ export function KbnAggTable(config, RecursionHelper) { if (typeof $scope.dimensions === 'undefined') return; - const { buckets, metrics } = $scope.dimensions; + const { buckets, metrics, splitColumn } = $scope.dimensions; $scope.formattedColumns = table.columns .map(function(col, i) { const isBucket = buckets.find(bucket => bucket.accessor === i); - const dimension = isBucket || metrics.find(metric => metric.accessor === i); + const isSplitColumn = splitColumn + ? splitColumn.find(splitColumn => splitColumn.accessor === i) + : undefined; + const dimension = + isBucket || isSplitColumn || metrics.find(metric => metric.accessor === i); if (!dimension) return; @@ -135,18 +143,15 @@ export function KbnAggTable(config, RecursionHelper) { } const isDate = - _.get(dimension, 'format.id') === 'date' || - _.get(dimension, 'format.params.id') === 'date'; - const isNumeric = - _.get(dimension, 'format.id') === 'number' || - _.get(dimension, 'format.params.id') === 'number'; + dimension.format?.id === 'date' || dimension.format?.params?.id === 'date'; + const allowsNumericalAggregations = formatter?.allowsNumericalAggregations; let { totalFunc } = $scope; if (typeof totalFunc === 'undefined' && showPercentage) { totalFunc = 'sum'; } - if (isNumeric || isDate || totalFunc === 'count') { + if (allowsNumericalAggregations || isDate || totalFunc === 'count') { const sum = tableRows => { return _.reduce( tableRows, @@ -161,7 +166,6 @@ export function KbnAggTable(config, RecursionHelper) { }; formattedColumn.sumTotal = sum(table.rows); - switch (totalFunc) { case 'sum': { if (!isDate) { diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js index 982aca8d3b8132..d269d7c3546ec7 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js @@ -79,10 +79,14 @@ export class TimeseriesVisualization extends Component { static getYAxisDomain = model => { const axisMin = get(model, 'axis_min', '').toString(); const axisMax = get(model, 'axis_max', '').toString(); + const fit = model.series + ? model.series.filter(({ hidden }) => !hidden).every(({ fill }) => fill === '0') + : model.fill === '0'; return { min: axisMin.length ? Number(axisMin) : undefined, max: axisMax.length ? Number(axisMax) : undefined, + fit, }; }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index 59c6bddb64521d..cc2ab133941db5 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -445,6 +445,8 @@ export const buildVislibDimensions = async ( dimensions.x.params.date = true; const { esUnit, esValue } = xAgg.buckets.getInterval(); dimensions.x.params.interval = moment.duration(esValue, esUnit); + dimensions.x.params.intervalESValue = esValue; + dimensions.x.params.intervalESUnit = esUnit; dimensions.x.params.format = xAgg.buckets.getScaledDateFormat(); dimensions.x.params.bounds = xAgg.buckets.getBounds(); } else if (xAgg.type.name === 'histogram') { diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 98675402b43ccc..747ad025ef691d 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -20,6 +20,7 @@ @import './saved_objects/index'; @import './share/index'; @import './style_compile/index'; +@import '../../../plugins/management/public/components/index'; // The following are prefixed with "vis" diff --git a/src/legacy/ui/public/agg_response/point_series/__tests__/_init_x_axis.js b/src/legacy/ui/public/agg_response/point_series/__tests__/_init_x_axis.js index 929aa4d5a7a9f5..a8512edee658b5 100644 --- a/src/legacy/ui/public/agg_response/point_series/__tests__/_init_x_axis.js +++ b/src/legacy/ui/public/agg_response/point_series/__tests__/_init_x_axis.js @@ -104,6 +104,8 @@ describe('initXAxis', function() { it('reads the date interval param from the x agg', function() { chart.aspects.x[0].params.interval = 'P1D'; + chart.aspects.x[0].params.intervalESValue = 1; + chart.aspects.x[0].params.intervalESUnit = 'd'; chart.aspects.x[0].params.date = true; initXAxis(chart, table); expect(chart) @@ -113,6 +115,8 @@ describe('initXAxis', function() { expect(moment.isDuration(chart.ordered.interval)).to.be(true); expect(chart.ordered.interval.toISOString()).to.eql('P1D'); + expect(chart.ordered.intervalESValue).to.be(1); + expect(chart.ordered.intervalESUnit).to.be('d'); }); it('reads the numeric interval param from the x agg', function() { diff --git a/src/legacy/ui/public/agg_response/point_series/_init_x_axis.js b/src/legacy/ui/public/agg_response/point_series/_init_x_axis.js index 531c564ea19d6f..4a81486783b085 100644 --- a/src/legacy/ui/public/agg_response/point_series/_init_x_axis.js +++ b/src/legacy/ui/public/agg_response/point_series/_init_x_axis.js @@ -28,9 +28,19 @@ export function initXAxis(chart, table) { chart.xAxisFormat = format; chart.xAxisLabel = title; - if (params.interval) { - chart.ordered = { - interval: params.date ? moment.duration(params.interval) : params.interval, - }; + const { interval, date } = params; + if (interval) { + if (date) { + const { intervalESUnit, intervalESValue } = params; + chart.ordered = { + interval: moment.duration(interval), + intervalESUnit: intervalESUnit, + intervalESValue: intervalESValue, + }; + } else { + chart.ordered = { + interval, + }; + } } } diff --git a/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js b/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js index 942f39fc98dec1..12bf5c1cce0043 100644 --- a/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js +++ b/src/legacy/ui/public/field_editor/components/scripting_help/test_script.js @@ -30,6 +30,8 @@ import { EuiTitle, EuiCallOut, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; const { SearchBar } = npStart.plugins.data.ui; @@ -116,7 +118,13 @@ export class TestScript extends Component { if (previewData.error) { return ( - + -

First 10 results

+

+ +

{ - return !field.name.startsWith('_'); + const isMultiField = field.subType && field.subType.multi; + return !field.name.startsWith('_') && !isMultiField && !field.scripted; }) .forEach(field => { if (fieldsByTypeMap.has(field.type)) { @@ -180,9 +194,16 @@ export class TestScript extends Component { return ( - + - Run script + } /> @@ -219,11 +243,19 @@ export class TestScript extends Component { -

Preview results

+

+ +

- Run your script to preview the first 10 results. You can also select some additional - fields to include in your results to gain more context or add a query to filter on - specific documents. +

diff --git a/src/legacy/ui/public/management/_index.scss b/src/legacy/ui/public/management/_index.scss deleted file mode 100644 index 30ac0c9fe9b27d..00000000000000 --- a/src/legacy/ui/public/management/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './components/index'; \ No newline at end of file diff --git a/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap b/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap deleted file mode 100644 index 3364bee33a5443..00000000000000 --- a/src/legacy/ui/public/management/components/__snapshots__/sidebar_nav.test.ts.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Management filters and filters and maps section objects into SidebarNav items 1`] = ` -Array [ - Object { - "data-test-subj": "activeSection", - "href": undefined, - "icon": null, - "id": "activeSection", - "isSelected": false, - "items": Array [ - Object { - "data-test-subj": "item", - "href": undefined, - "icon": null, - "id": "item", - "isSelected": false, - "name": "item", - }, - ], - "name": "activeSection", - }, -] -`; diff --git a/src/legacy/ui/public/management/components/sidebar_nav.tsx b/src/legacy/ui/public/management/components/sidebar_nav.tsx deleted file mode 100644 index cd3d85090dce01..00000000000000 --- a/src/legacy/ui/public/management/components/sidebar_nav.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { EuiIcon, EuiSideNav, IconType, EuiScreenReaderOnly } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { IndexedArray } from 'ui/indexed_array'; - -interface Subsection { - disabled: boolean; - visible: boolean; - id: string; - display: string; - url?: string; - icon?: IconType; -} -interface Section extends Subsection { - visibleItems: IndexedArray; -} - -const sectionVisible = (section: Subsection) => !section.disabled && section.visible; -const sectionToNav = (selectedId: string) => ({ display, id, url, icon }: Subsection) => ({ - id, - name: display, - icon: icon ? : null, - isSelected: selectedId === id, - href: url, - 'data-test-subj': id, -}); - -export const sideNavItems = (sections: Section[], selectedId: string) => - sections - .filter(sectionVisible) - .filter(section => section.visibleItems.filter(sectionVisible).length) - .map(section => ({ - items: section.visibleItems.filter(sectionVisible).map(sectionToNav(selectedId)), - ...sectionToNav(selectedId)(section), - })); - -interface SidebarNavProps { - sections: Section[]; - selectedId: string; -} - -interface SidebarNavState { - isSideNavOpenOnMobile: boolean; -} - -export class SidebarNav extends React.Component { - constructor(props: SidebarNavProps) { - super(props); - this.state = { - isSideNavOpenOnMobile: false, - }; - } - - public render() { - const HEADER_ID = 'management-nav-header'; - - return ( - <> - -

- {i18n.translate('common.ui.management.nav.label', { - defaultMessage: 'Management', - })} -

-
- - - ); - } - - private renderMobileTitle() { - return ; - } - - private toggleOpenOnMobile = () => { - this.setState({ - isSideNavOpenOnMobile: !this.state.isSideNavOpenOnMobile, - }); - }; -} diff --git a/src/legacy/ui/public/management/index.js b/src/legacy/ui/public/management/index.js index ed8ddb65315e2c..b2f1946dbc59cb 100644 --- a/src/legacy/ui/public/management/index.js +++ b/src/legacy/ui/public/management/index.js @@ -23,8 +23,6 @@ export { PAGE_FOOTER_COMPONENT, } from '../../../core_plugins/kibana/public/management/sections/settings/components/default_component_registry'; export { registerSettingsComponent } from '../../../core_plugins/kibana/public/management/sections/settings/components/component_registry'; -export { SidebarNav } from './components'; export { MANAGEMENT_BREADCRUMB } from './breadcrumbs'; - import { npStart } from 'ui/new_platform'; export const management = npStart.plugins.management.legacy; diff --git a/src/legacy/ui/public/new_platform/new_platform.test.ts b/src/legacy/ui/public/new_platform/new_platform.test.ts index cd1af311d4eff0..e050ffd5b530cc 100644 --- a/src/legacy/ui/public/new_platform/new_platform.test.ts +++ b/src/legacy/ui/public/new_platform/new_platform.test.ts @@ -62,6 +62,7 @@ describe('ui/new_platform', () => { expect(mountMock).toHaveBeenCalledWith({ element: elementMock[0], appBasePath: '/test/base/path/app/test', + onAppLeave: expect.any(Function), }); }); @@ -82,6 +83,7 @@ describe('ui/new_platform', () => { expect(mountMock).toHaveBeenCalledWith(expect.any(Object), { element: elementMock[0], appBasePath: '/test/base/path/app/test', + onAppLeave: expect.any(Function), }); }); diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index df3fa7c6ea466c..c948565bb08351 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -124,7 +124,11 @@ export const legacyAppRegister = (app: App) => { // Root controller cannot return a Promise so use an internal async function and call it immediately (async () => { - const params = { element, appBasePath: npSetup.core.http.basePath.prepend(`/app/${app.id}`) }; + const params = { + element, + appBasePath: npSetup.core.http.basePath.prepend(`/app/${app.id}`), + onAppLeave: () => undefined, + }; const unmount = isAppMountDeprecated(app.mount) ? await app.mount({ core: npStart.core }, params) : await app.mount(params); diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs index d8a55935b705a0..85b6de26b95164 100644 --- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs +++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs @@ -14,6 +14,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { window.onload = function () { var files = [ '{{dllBundlePath}}/vendors.bundle.dll.js', + '{{regularBundlePath}}/kbn-ui-shared-deps/{{sharedDepsFilename}}', '{{regularBundlePath}}/commons.bundle.js', '{{regularBundlePath}}/{{appId}}.bundle.js' ]; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 0b266b8b62726d..a935270d23fced 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -21,6 +21,7 @@ import { createHash } from 'crypto'; import Boom from 'boom'; import { resolve } from 'path'; import { i18n } from '@kbn/i18n'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { AppBootstrap } from './bootstrap'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; @@ -41,18 +42,10 @@ export function uiRenderMixin(kbnServer, server, config) { // render all views from ./views server.setupViews(resolve(__dirname, 'views')); - server.exposeStaticDir( - '/node_modules/@elastic/eui/dist/{path*}', - fromRoot('node_modules/@elastic/eui/dist') - ); server.exposeStaticDir( '/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist') ); - server.exposeStaticDir( - '/node_modules/@elastic/charts/dist/{path*}', - fromRoot('node_modules/@elastic/charts/dist') - ); const translationsCache = { translations: null, hash: null }; server.route({ @@ -114,14 +107,12 @@ export function uiRenderMixin(kbnServer, server, config) { `${dllBundlePath}/vendors.style.dll.css`, ...(darkMode ? [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`, ] : [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`, ]), `${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`, `${regularBundlePath}/commons.style.css`, @@ -142,6 +133,7 @@ export function uiRenderMixin(kbnServer, server, config) { regularBundlePath, dllBundlePath, styleSheetPaths, + sharedDepsFilename: UiSharedDeps.distFilename, }, }); diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index 9a21a4b1d5439e..efff7f0aa2b46b 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -19,6 +19,7 @@ import { writeFile } from 'fs'; import os from 'os'; + import Boom from 'boom'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; @@ -26,10 +27,10 @@ import webpack from 'webpack'; import Stats from 'webpack/lib/Stats'; import * as threadLoader from 'thread-loader'; import webpackMerge from 'webpack-merge'; -import { DynamicDllPlugin } from './dynamic_dll_plugin'; import WrapperPlugin from 'wrapper-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; -import { defaults } from 'lodash'; +import { DynamicDllPlugin } from './dynamic_dll_plugin'; import { IS_KIBANA_DISTRIBUTABLE } from '../legacy/utils'; import { fromRoot } from '../core/server/utils'; @@ -403,6 +404,10 @@ export default class BaseOptimizer { // and not for the webpack compilations performance itself hints: false, }, + + externals: { + ...UiSharedDeps.externals, + }, }; // when running from the distributable define an environment variable we can use @@ -417,17 +422,6 @@ export default class BaseOptimizer { ], }; - // We need to add react-addons (and a few other bits) for enzyme to work. - // https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md - const supportEnzymeConfig = { - externals: { - mocha: 'mocha', - 'react/lib/ExecutionEnvironment': true, - 'react/addons': true, - 'react/lib/ReactContext': true, - }, - }; - const watchingConfig = { plugins: [ new webpack.WatchIgnorePlugin([ @@ -482,9 +476,7 @@ export default class BaseOptimizer { IS_CODE_COVERAGE ? coverageConfig : {}, commonConfig, IS_KIBANA_DISTRIBUTABLE ? isDistributableConfig : {}, - this.uiBundles.isDevMode() - ? webpackMerge(watchingConfig, supportEnzymeConfig) - : productionConfig + this.uiBundles.isDevMode() ? watchingConfig : productionConfig ) ); } @@ -515,22 +507,19 @@ export default class BaseOptimizer { } failedStatsToError(stats) { - const details = stats.toString( - defaults( - { colors: true, warningsFilter: STATS_WARNINGS_FILTER }, - Stats.presetToOptions('minimal') - ) - ); + const details = stats.toString({ + ...Stats.presetToOptions('minimal'), + colors: true, + warningsFilter: STATS_WARNINGS_FILTER, + }); return Boom.internal( `Optimizations failure.\n${details.split('\n').join('\n ')}\n`, - stats.toJson( - defaults({ - warningsFilter: STATS_WARNINGS_FILTER, - ...Stats.presetToOptions('detailed'), - maxModules: 1000, - }) - ) + stats.toJson({ + warningsFilter: STATS_WARNINGS_FILTER, + ...Stats.presetToOptions('detailed'), + maxModules: 1000, + }) ); } diff --git a/src/optimize/bundles_route/bundles_route.js b/src/optimize/bundles_route/bundles_route.js index d3c08fae922647..f0261d44e03474 100644 --- a/src/optimize/bundles_route/bundles_route.js +++ b/src/optimize/bundles_route/bundles_route.js @@ -19,6 +19,7 @@ import { isAbsolute, extname } from 'path'; import LruCache from 'lru-cache'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { createDynamicAssetResponse } from './dynamic_asset_response'; /** @@ -66,6 +67,12 @@ export function createBundlesRoute({ } return [ + buildRouteForBundles( + `${basePublicPath}/bundles/kbn-ui-shared-deps/`, + '/bundles/kbn-ui-shared-deps/', + UiSharedDeps.distDir, + fileHashCache + ), buildRouteForBundles( `${basePublicPath}/bundles/`, '/bundles/', diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js index 2a3d3dd659c67e..ecf5def5aa6cac 100644 --- a/src/optimize/dynamic_dll_plugin/dll_config_model.js +++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js @@ -23,6 +23,7 @@ import webpack from 'webpack'; import webpackMerge from 'webpack-merge'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; function generateDLL(config) { const { @@ -145,6 +146,9 @@ function generateDLL(config) { // and not for the webpack compilations performance itself hints: false, }, + externals: { + ...UiSharedDeps.externals, + }, }; } diff --git a/src/optimize/watch/watch_cache.ts b/src/optimize/watch/watch_cache.ts index ab11a8c5d2f118..15957210b3d43c 100644 --- a/src/optimize/watch/watch_cache.ts +++ b/src/optimize/watch/watch_cache.ts @@ -18,17 +18,18 @@ */ import { createHash } from 'crypto'; -import { readFile, writeFile } from 'fs'; +import { readFile, writeFile, readdir, unlink, rmdir } from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; - +import path from 'path'; import del from 'del'; -import deleteEmpty from 'delete-empty'; -import globby from 'globby'; import normalizePosixPath from 'normalize-path'; const readAsync = promisify(readFile); const writeAsync = promisify(writeFile); +const readdirAsync = promisify(readdir); +const unlinkAsync = promisify(unlink); +const rmdirAsync = promisify(rmdir); interface Params { logWithMetadata: (tags: string[], message: string, metadata?: { [key: string]: any }) => void; @@ -95,11 +96,7 @@ export class WatchCache { await del(this.statePath, { force: true }); // delete everything in optimize/.cache directory - await del(await globby([normalizePosixPath(this.cachePath)], { dot: true })); - - // delete some empty folder that could be left - // from the previous cache path reset action - await deleteEmpty(this.cachePath); + await recursiveDelete(normalizePosixPath(this.cachePath)); // delete dlls await del(this.dllsPath); @@ -167,3 +164,28 @@ export class WatchCache { } } } + +/** + * Recursively deletes a folder. This is a workaround for a bug in `del` where + * very large folders (with 84K+ files) cause a stack overflow. + */ +async function recursiveDelete(directory: string) { + const entries = await readdirAsync(directory, { withFileTypes: true }); + await Promise.all( + entries.map(entry => { + const absolutePath = path.join(directory, entry.name); + const result = entry.isDirectory() + ? recursiveDelete(absolutePath) + : unlinkAsync(absolutePath); + + // Ignore errors, if the file or directory doesn't exist. + return result.catch(e => { + if (e.code !== 'ENOENT') { + throw e; + } + }); + }) + ); + + return rmdirAsync(directory); +} diff --git a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts index 7c90119fcc1bc3..0d5cd6ea17f165 100644 --- a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts +++ b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts @@ -41,7 +41,7 @@ const grammarRuleTranslations: Record = { interface KQLSyntaxErrorData extends Error { found: string; - expected: KQLSyntaxErrorExpected[]; + expected: KQLSyntaxErrorExpected[] | null; location: any; } @@ -53,19 +53,22 @@ export class KQLSyntaxError extends Error { shortMessage: string; constructor(error: KQLSyntaxErrorData, expression: any) { - const translatedExpectations = error.expected.map(expected => { - return grammarRuleTranslations[expected.description] || expected.description; - }); + let message = error.message; + if (error.expected) { + const translatedExpectations = error.expected.map(expected => { + return grammarRuleTranslations[expected.description] || expected.description; + }); - const translatedExpectationText = translatedExpectations.join(', '); + const translatedExpectationText = translatedExpectations.join(', '); - const message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', { - defaultMessage: 'Expected {expectedList} but {foundInput} found.', - values: { - expectedList: translatedExpectationText, - foundInput: error.found ? `"${error.found}"` : endOfInputText, - }, - }); + message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', { + defaultMessage: 'Expected {expectedList} but {foundInput} found.', + values: { + expectedList: translatedExpectationText, + foundInput: error.found ? `"${error.found}"` : endOfInputText, + }, + }); + } const fullMessage = [message, expression, repeat('-', error.location.start.offset) + '^'].join( '\n' diff --git a/src/plugins/data/common/field_formats/converters/bytes.ts b/src/plugins/data/common/field_formats/converters/bytes.ts index 6c6df5eb7367d8..f1110add3e7ded 100644 --- a/src/plugins/data/common/field_formats/converters/bytes.ts +++ b/src/plugins/data/common/field_formats/converters/bytes.ts @@ -26,4 +26,5 @@ export class BytesFormat extends NumeralFormat { id = BytesFormat.id; title = BytesFormat.title; + allowsNumericalAggregations = true; } diff --git a/src/plugins/data/common/field_formats/converters/duration.ts b/src/plugins/data/common/field_formats/converters/duration.ts index d02de1a2fd8893..8caa11be5ca793 100644 --- a/src/plugins/data/common/field_formats/converters/duration.ts +++ b/src/plugins/data/common/field_formats/converters/duration.ts @@ -171,6 +171,7 @@ export class DurationFormat extends FieldFormat { static fieldType = KBN_FIELD_TYPES.NUMBER; static inputFormats = inputFormats; static outputFormats = outputFormats; + allowsNumericalAggregations = true; isHuman() { return this.param('outputFormat') === HUMAN_FRIENDLY; diff --git a/src/plugins/data/common/field_formats/converters/number.ts b/src/plugins/data/common/field_formats/converters/number.ts index 6969c1551e1ccb..686329e887682d 100644 --- a/src/plugins/data/common/field_formats/converters/number.ts +++ b/src/plugins/data/common/field_formats/converters/number.ts @@ -26,4 +26,5 @@ export class NumberFormat extends NumeralFormat { id = NumberFormat.id; title = NumberFormat.title; + allowsNumericalAggregations = true; } diff --git a/src/plugins/data/common/field_formats/converters/percent.ts b/src/plugins/data/common/field_formats/converters/percent.ts index 2ae32c7c77f076..d839a54dd0c2cd 100644 --- a/src/plugins/data/common/field_formats/converters/percent.ts +++ b/src/plugins/data/common/field_formats/converters/percent.ts @@ -26,6 +26,7 @@ export class PercentFormat extends NumeralFormat { id = PercentFormat.id; title = PercentFormat.title; + allowsNumericalAggregations = true; getParamDefaults = () => ({ pattern: this.getConfig!('format:percent:defaultPattern'), diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap index 2fce33793cd465..6f5f9b39561876 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap @@ -346,6 +346,7 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "remove": [MockFunction], "replace": [MockFunction], }, + "openConfirm": [MockFunction], "openFlyout": [MockFunction], "openModal": [MockFunction], }, @@ -965,6 +966,7 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "remove": [MockFunction], "replace": [MockFunction], }, + "openConfirm": [MockFunction], "openFlyout": [MockFunction], "openModal": [MockFunction], }, @@ -1572,6 +1574,7 @@ exports[`QueryStringInput Should pass the query language to the language switche "remove": [MockFunction], "replace": [MockFunction], }, + "openConfirm": [MockFunction], "openFlyout": [MockFunction], "openModal": [MockFunction], }, @@ -2188,6 +2191,7 @@ exports[`QueryStringInput Should pass the query language to the language switche "remove": [MockFunction], "replace": [MockFunction], }, + "openConfirm": [MockFunction], "openFlyout": [MockFunction], "openModal": [MockFunction], }, @@ -2795,6 +2799,7 @@ exports[`QueryStringInput Should render the given query 1`] = ` "remove": [MockFunction], "replace": [MockFunction], }, + "openConfirm": [MockFunction], "openFlyout": [MockFunction], "openModal": [MockFunction], }, @@ -3411,6 +3416,7 @@ exports[`QueryStringInput Should render the given query 1`] = ` "remove": [MockFunction], "replace": [MockFunction], }, + "openConfirm": [MockFunction], "openFlyout": [MockFunction], "openModal": [MockFunction], }, diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index be142f2cc74e65..a179be6946c766 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -91,7 +91,7 @@ function DevToolsWrapper({ if (mountedTool.current) { mountedTool.current.unmountHandler(); } - const params = { element, appBasePath: '' }; + const params = { element, appBasePath: '', onAppLeave: () => undefined }; const unmountHandler = isAppMountDeprecated(activeDevTool.mount) ? await activeDevTool.mount(appMountContext, params) : await activeDevTool.mount(params); diff --git a/src/plugins/es_ui_shared/static/forms/components/field.tsx b/src/plugins/es_ui_shared/static/forms/components/field.tsx index 89dea53d75b38a..5b9a6dc9de002d 100644 --- a/src/plugins/es_ui_shared/static/forms/components/field.tsx +++ b/src/plugins/es_ui_shared/static/forms/components/field.tsx @@ -37,6 +37,7 @@ import { RadioGroupField, RangeField, SelectField, + SuperSelectField, ToggleField, } from './fields'; @@ -50,6 +51,7 @@ const mapTypeToFieldComponent = { [FIELD_TYPES.RADIO_GROUP]: RadioGroupField, [FIELD_TYPES.RANGE]: RangeField, [FIELD_TYPES.SELECT]: SelectField, + [FIELD_TYPES.SUPER_SELECT]: SuperSelectField, [FIELD_TYPES.TOGGLE]: ToggleField, }; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/index.ts b/src/plugins/es_ui_shared/static/forms/components/fields/index.ts index f973bb7b04d346..35635d0e8530c9 100644 --- a/src/plugins/es_ui_shared/static/forms/components/fields/index.ts +++ b/src/plugins/es_ui_shared/static/forms/components/fields/index.ts @@ -25,5 +25,6 @@ export * from './multi_select_field'; export * from './radio_group_field'; export * from './range_field'; export * from './select_field'; +export * from './super_select_field'; export * from './toggle_field'; export * from './text_area_field'; diff --git a/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx b/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx new file mode 100644 index 00000000000000..9b29d75230d7a6 --- /dev/null +++ b/src/plugins/es_ui_shared/static/forms/components/fields/super_select_field.tsx @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../hook_form_lib'; + +interface Props { + field: FieldHook; + euiFieldProps?: Record; + idAria?: string; + [key: string]: any; +} + +export const SuperSelectField = ({ field, euiFieldProps = {}, ...rest }: Props) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + + { + field.setValue(value); + }} + options={[]} + isInvalid={isInvalid} + data-test-subj="select" + {...euiFieldProps} + /> + + ); +}; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts index df2807e59ab465..4056947483107c 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/constants.ts @@ -28,6 +28,7 @@ export const FIELD_TYPES = { RADIO_GROUP: 'radioGroup', RANGE: 'range', SELECT: 'select', + SUPER_SELECT: 'superSelect', MULTI_SELECT: 'multiSelect', }; diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index 0ae77995c0502f..62440f12c6d849 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -78,6 +78,13 @@ export interface Props { */ hoverProvider?: monacoEditor.languages.HoverProvider; + /** + * Language config provider for bracket + * Documentation for the provider can be found here: + * https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.languageconfiguration.html + */ + languageConfiguration?: monacoEditor.languages.LanguageConfiguration; + /** * Function called before the editor is mounted in the view */ @@ -130,6 +137,13 @@ export class CodeEditor extends React.Component { if (this.props.hoverProvider) { monaco.languages.registerHoverProvider(this.props.languageId, this.props.hoverProvider); } + + if (this.props.languageConfiguration) { + monaco.languages.setLanguageConfiguration( + this.props.languageId, + this.props.languageConfiguration + ); + } }); // Register the theme diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 10b7dd2b4da449..cfe89f16e99dd5 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -25,4 +25,5 @@ export * from './overlays'; export * from './ui_settings'; export * from './field_icon'; export * from './table_list_view'; +export { useUrlTracker } from './use_url_tracker'; export { toMountPoint } from './util'; diff --git a/src/legacy/ui/public/management/components/index.ts b/src/plugins/kibana_react/public/use_url_tracker/index.ts similarity index 94% rename from src/legacy/ui/public/management/components/index.ts rename to src/plugins/kibana_react/public/use_url_tracker/index.ts index e3a18ec4e2698f..fdceaf34e04ee7 100644 --- a/src/legacy/ui/public/management/components/index.ts +++ b/src/plugins/kibana_react/public/use_url_tracker/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { SidebarNav } from './sidebar_nav'; +export { useUrlTracker } from './use_url_tracker'; diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx new file mode 100644 index 00000000000000..d1425a09b2f9c5 --- /dev/null +++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useUrlTracker } from './use_url_tracker'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createMemoryHistory } from 'history'; + +describe('useUrlTracker', () => { + const key = 'key'; + let storage = new StubBrowserStorage(); + let history = createMemoryHistory(); + beforeEach(() => { + storage = new StubBrowserStorage(); + history = createMemoryHistory(); + }); + + it('should track history changes and save them to storage', () => { + expect(storage.getItem(key)).toBeNull(); + const { unmount } = renderHook(() => { + useUrlTracker(key, history, () => false, storage); + }); + expect(storage.getItem(key)).toBe('/'); + history.push('/change'); + expect(storage.getItem(key)).toBe('/change'); + unmount(); + history.push('/other-change'); + expect(storage.getItem(key)).toBe('/change'); + }); + + it('by default should restore initial url', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, undefined, storage); + }); + expect(history.location.pathname).toBe('/change'); + }); + + it('should restore initial url if shouldRestoreUrl cb returns true', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, () => true, storage); + }); + expect(history.location.pathname).toBe('/change'); + }); + + it('should not restore initial url if shouldRestoreUrl cb returns false', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, () => false, storage); + }); + expect(history.location.pathname).toBe('/'); + }); +}); diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx new file mode 100644 index 00000000000000..97e69fe22a8420 --- /dev/null +++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { History } from 'history'; +import { useLayoutEffect } from 'react'; +import { createUrlTracker } from '../../../kibana_utils/public/'; + +/** + * State management url_tracker in react hook form + * + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage so it could be restored if navigated back to the app + * + * @param key - key to use in storage + * @param history - history instance to use + * @param shouldRestoreUrl - cb if url should be restored + * @param storage - storage to use. window.sessionStorage is default + */ +export function useUrlTracker( + key: string, + history: History, + shouldRestoreUrl: (urlToRestore: string) => boolean = () => true, + storage: Storage = sessionStorage +) { + useLayoutEffect(() => { + const urlTracker = createUrlTracker(key, storage); + const urlToRestore = urlTracker.getTrackedUrl(); + if (urlToRestore && shouldRestoreUrl(urlToRestore)) { + history.replace(urlToRestore); + } + const stopTrackingUrl = urlTracker.startTrackingUrl(history); + return () => { + stopTrackingUrl(); + }; + }, [key, history]); +} diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts new file mode 100644 index 00000000000000..24f8f13f214788 --- /dev/null +++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Subject } from 'rxjs'; +import { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value'; +import { toArray } from 'rxjs/operators'; +import deepEqual from 'fast-deep-equal'; + +describe('distinctUntilChangedWithInitialValue', () => { + it('should skip updates with the same value', async () => { + const subject = new Subject(); + const result = subject.pipe(distinctUntilChangedWithInitialValue(1), toArray()).toPromise(); + + subject.next(2); + subject.next(3); + subject.next(3); + subject.next(3); + subject.complete(); + + expect(await result).toEqual([2, 3]); + }); + + it('should accept promise as initial value', async () => { + const subject = new Subject(); + const result = subject + .pipe( + distinctUntilChangedWithInitialValue( + new Promise(resolve => { + resolve(1); + setTimeout(() => { + subject.next(2); + subject.next(3); + subject.next(3); + subject.next(3); + subject.complete(); + }); + }) + ), + toArray() + ) + .toPromise(); + expect(await result).toEqual([2, 3]); + }); + + it('should accept custom comparator', async () => { + const subject = new Subject(); + const result = subject + .pipe(distinctUntilChangedWithInitialValue({ test: 1 }, deepEqual), toArray()) + .toPromise(); + + subject.next({ test: 1 }); + subject.next({ test: 2 }); + subject.next({ test: 2 }); + subject.next({ test: 3 }); + subject.complete(); + + expect(await result).toEqual([{ test: 2 }, { test: 3 }]); + }); +}); diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts new file mode 100644 index 00000000000000..6af9cc1e8ac3ae --- /dev/null +++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MonoTypeOperatorFunction, queueScheduler, scheduled, from } from 'rxjs'; +import { concatAll, distinctUntilChanged, skip } from 'rxjs/operators'; + +export function distinctUntilChangedWithInitialValue( + initialValue: T | Promise, + compare?: (x: T, y: T) => boolean +): MonoTypeOperatorFunction { + return input$ => + scheduled( + [isPromise(initialValue) ? from(initialValue) : [initialValue], input$], + queueScheduler + ).pipe(concatAll(), distinctUntilChanged(compare), skip(1)); +} + +function isPromise(value: T | Promise): value is Promise { + return ( + !!value && + typeof value === 'object' && + 'then' in value && + typeof value.then === 'function' && + !('subscribe' in value) + ); +} diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index d13a250cedf2e3..eb3bb96c8e8740 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -18,3 +18,4 @@ */ export * from './defer'; +export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value'; diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts index 4e792ceef117a5..5c50e152ad46ca 100644 --- a/src/plugins/kibana_utils/demos/demos.test.ts +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -19,6 +19,7 @@ import { result as counterResult } from './state_containers/counter'; import { result as todomvcResult } from './state_containers/todomvc'; +import { result as urlSyncResult } from './state_sync/url'; describe('demos', () => { describe('state containers', () => { @@ -33,4 +34,12 @@ describe('demos', () => { ]); }); }); + + describe('state sync', () => { + test('url sync demo works', async () => { + expect(await urlSyncResult).toMatchInlineSnapshot( + `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` + ); + }); + }); }); diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts new file mode 100644 index 00000000000000..657b64f55a7766 --- /dev/null +++ b/src/plugins/kibana_utils/demos/state_sync/url.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; +import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; +import { + createKbnUrlStateStorage, + syncState, + INullableBaseStateContainer, +} from '../../public/state_sync'; + +const tick = () => new Promise(resolve => setTimeout(resolve)); + +const stateContainer = createStateContainer(defaultState, pureTransitions); +const { start, stop } = syncState({ + stateContainer: withDefaultState(stateContainer, defaultState), + storageKey: '_s', + stateStorage: createKbnUrlStateStorage(), +}); + +start(); +export const result = Promise.resolve() + .then(() => { + // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers')" + + stateContainer.transitions.add({ + id: 2, + text: 'test', + completed: false, + }); + + // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers'),(completed:!f,id:2,text:test))" + + /* actual url updates happens async */ + return tick(); + }) + .then(() => { + stop(); + return window.location.href; + }); + +function withDefaultState( + // eslint-disable-next-line no-shadow + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + stateContainer.set(state || defaultState); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts index 72f3716147efa7..99b49b401a8b8e 100644 --- a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts +++ b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts @@ -19,7 +19,9 @@ import { mapValues, isString } from 'lodash'; import { FieldMappingSpec, MappingObject } from './types'; -import { ES_FIELD_TYPES } from '../../../data/public'; + +// import from ./common/types to prevent circular dependency of kibana_utils <-> data plugin +import { ES_FIELD_TYPES } from '../../../data/common/types'; /** @private */ type ShorthandFieldMapObject = FieldMappingSpec | ES_FIELD_TYPES | 'json'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index af2fc9e31b21bb..0ba444c4e93956 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -27,6 +27,34 @@ export * from './render_complete'; export * from './resize_checker'; export * from './state_containers'; export * from './storage'; -export * from './storage/hashed_item_store'; -export * from './state_management/state_hash'; -export * from './state_management/url'; +export { hashedItemStore, HashedItemStore } from './storage/hashed_item_store'; +export { + createStateHash, + persistState, + retrieveState, + isStateHash, +} from './state_management/state_hash'; +export { + hashQuery, + hashUrl, + unhashUrl, + unhashQuery, + createUrlTracker, + createKbnUrlControls, + getStateFromKbnUrl, + getStatesFromKbnUrl, + setStateToKbnUrl, +} from './state_management/url'; +export { + syncState, + syncStates, + createKbnUrlStateStorage, + createSessionStorageStateStorage, + IStateSyncConfig, + ISyncStateRef, + IKbnUrlStateStorage, + INullableBaseStateContainer, + ISessionStorageStateStorage, + StartSyncStateFnType, + StopSyncStateFnType, +} from './state_sync'; diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts index 9165181299a90a..95f4c35f2ce01b 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts @@ -113,6 +113,13 @@ test('multiple subscribers can subscribe', () => { expect(spy2.mock.calls[1][0]).toEqual({ a: 2 }); }); +test('can create state container without transitions', () => { + const state = { foo: 'bar' }; + const stateContainer = createStateContainer(state); + expect(stateContainer.transitions).toEqual({}); + expect(stateContainer.get()).toEqual(state); +}); + test('creates impure mutators from pure mutators', () => { const { mutators } = create( {}, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts index 1ef4a1c0128170..b949a9daed0ae6 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts @@ -41,11 +41,11 @@ const freeze: (value: T) => RecursiveReadonly = export const createStateContainer = < State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} >( defaultState: State, - pureTransitions: PureTransitions, + pureTransitions: PureTransitions = {} as PureTransitions, pureSelectors: PureSelectors = {} as PureSelectors ): ReduxLikeStateContainer => { const data$ = new BehaviorSubject>(freeze(defaultState)); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx index 8f5810f3e147d3..c1a35441b637b4 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx @@ -193,12 +193,7 @@ describe('hooks', () => { describe('useTransitions', () => { test('useTransitions hook returns mutations that can update state', () => { - const { store } = create< - { - cnt: number; - }, - any - >( + const { store } = create( { cnt: 0, }, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts index e94165cc483760..45b34b13251f44 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts @@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = useContainer().transitions; + const useTransitions = (): Container['transitions'] => useContainer().transitions; const useSelector = ( selector: (state: UnboxState) => Result, diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts index e0a1a18972635e..e120f60e72b8f3 100644 --- a/src/plugins/kibana_utils/public/state_containers/types.ts +++ b/src/plugins/kibana_utils/public/state_containers/types.ts @@ -42,7 +42,7 @@ export interface BaseStateContainer { export interface StateContainer< State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; @@ -51,7 +51,7 @@ export interface StateContainer< export interface ReduxLikeStateContainer< State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} > extends StateContainer { getState: () => RecursiveReadonly; diff --git a/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts new file mode 100644 index 00000000000000..c535e965aa772e --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import rison, { RisonValue } from 'rison-node'; +import { isStateHash, retrieveState, persistState } from '../state_hash'; + +// should be: +// export function decodeState(expandedOrHashedState: string) +// but this leads to the chain of types mismatches up to BaseStateContainer interfaces, +// as in state containers we don't have any restrictions on state shape +export function decodeState(expandedOrHashedState: string): State { + if (isStateHash(expandedOrHashedState)) { + return retrieveState(expandedOrHashedState); + } else { + return (rison.decode(expandedOrHashedState) as unknown) as State; + } +} + +// should be: +// export function encodeState(expandedOrHashedState: string) +// but this leads to the chain of types mismatches up to BaseStateContainer interfaces, +// as in state containers we don't have any restrictions on state shape +export function encodeState(state: State, useHash: boolean): string { + if (useHash) { + return persistState(state); + } else { + return rison.encode((state as unknown) as RisonValue); + } +} + +export function hashedStateToExpandedState(expandedOrHashedState: string): string { + if (isStateHash(expandedOrHashedState)) { + return encodeState(retrieveState(expandedOrHashedState), false); + } + + return expandedOrHashedState; +} + +export function expandedStateToHashedState(expandedOrHashedState: string): string { + if (isStateHash(expandedOrHashedState)) { + return expandedOrHashedState; + } + + return persistState(decodeState(expandedOrHashedState)); +} diff --git a/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts new file mode 100644 index 00000000000000..da1382720faff1 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + encodeState, + decodeState, + expandedStateToHashedState, + hashedStateToExpandedState, +} from './encode_decode_state'; diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts index 0e52c4c55872d2..24c3c57613477b 100644 --- a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts +++ b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './state_hash'; +export { isStateHash, createStateHash, persistState, retrieveState } from './state_hash'; diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts index a3eb5272b112da..f56d71297c030d 100644 --- a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts +++ b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { Sha256 } from '../../../../../core/public/utils'; import { hashedItemStore } from '../../storage/hashed_item_store'; @@ -52,3 +53,46 @@ export function createStateHash( export function isStateHash(str: string) { return String(str).indexOf(HASH_PREFIX) === 0; } + +export function retrieveState(stateHash: string): State { + const json = hashedItemStore.getItem(stateHash); + const throwUnableToRestoreUrlError = () => { + throw new Error( + i18n.translate('kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage', { + defaultMessage: + 'Unable to completely restore the URL, be sure to use the share functionality.', + }) + ); + }; + if (json === null) { + return throwUnableToRestoreUrlError(); + } + try { + return JSON.parse(json); + } catch (e) { + return throwUnableToRestoreUrlError(); + } +} + +export function persistState(state: State): string { + const json = JSON.stringify(state); + const hash = createStateHash(json); + + const isItemSet = hashedItemStore.setItem(hash, json); + if (isItemSet) return hash; + // If we ran out of space trying to persist the state, notify the user. + const message = i18n.translate( + 'kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage', + { + defaultMessage: + 'Kibana is unable to store history items in your session ' + + `because it is full and there don't seem to be items any items safe ` + + 'to delete.\n\n' + + 'This can usually be fixed by moving to a fresh tab, but could ' + + 'be caused by a larger issue. If you are seeing this message regularly, ' + + 'please file an issue at {gitHubIssuesUrl}.', + values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' }, + } + ); + throw new Error(message); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/format.test.ts b/src/plugins/kibana_utils/public/state_management/url/format.test.ts new file mode 100644 index 00000000000000..728f069840c72d --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/format.test.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { replaceUrlHashQuery } from './format'; + +describe('format', () => { + describe('replaceUrlHashQuery', () => { + it('should add hash query to url without hash', () => { + const url = 'http://localhost:5601/oxf/app/kibana'; + expect(replaceUrlHashQuery(url, () => ({ test: 'test' }))).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#?test=test"` + ); + }); + + it('should replace hash query', () => { + const url = 'http://localhost:5601/oxf/app/kibana#?test=test'; + expect( + replaceUrlHashQuery(url, query => ({ + ...query, + test1: 'test1', + })) + ).toMatchInlineSnapshot(`"http://localhost:5601/oxf/app/kibana#?test=test&test1=test1"`); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/format.ts b/src/plugins/kibana_utils/public/state_management/url/format.ts new file mode 100644 index 00000000000000..988ee086273823 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/format.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { format as formatUrl } from 'url'; +import { ParsedUrlQuery } from 'querystring'; +import { parseUrl, parseUrlHash } from './parse'; +import { stringifyQueryString } from './stringify_query_string'; + +export function replaceUrlHashQuery( + rawUrl: string, + queryReplacer: (query: ParsedUrlQuery) => ParsedUrlQuery +) { + const url = parseUrl(rawUrl); + const hash = parseUrlHash(rawUrl); + const newQuery = queryReplacer(hash?.query || {}); + const searchQueryString = stringifyQueryString(newQuery); + if ((!hash || !hash.search) && !searchQueryString) return rawUrl; // nothing to change. return original url + return formatUrl({ + ...url, + hash: formatUrl({ + pathname: hash?.pathname || '', + search: searchQueryString, + }), + }); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts index a85158acddefd5..ec87b8464ac2dc 100644 --- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts @@ -29,13 +29,6 @@ describe('hash unhash url', () => { describe('hash url', () => { describe('does nothing', () => { - it('if missing input', () => { - expect(() => { - // @ts-ignore - hashUrl(); - }).not.toThrowError(); - }); - it('if url is empty', () => { const url = ''; expect(hashUrl(url)).toBe(url); diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts index 872e7953f938bb..a29f8bb9ac635c 100644 --- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts +++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts @@ -17,13 +17,8 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import rison, { RisonObject } from 'rison-node'; -import { stringify as stringifyQueryString } from 'querystring'; -import encodeUriQuery from 'encode-uri-query'; -import { format as formatUrl, parse as parseUrl } from 'url'; -import { hashedItemStore } from '../../storage/hashed_item_store'; -import { createStateHash, isStateHash } from '../state_hash'; +import { expandedStateToHashedState, hashedStateToExpandedState } from '../state_encoder'; +import { replaceUrlHashQuery } from './format'; export type IParsedUrlQuery = Record; @@ -32,8 +27,8 @@ interface IUrlQueryMapperOptions { } export type IUrlQueryReplacerOptions = IUrlQueryMapperOptions; -export const unhashQuery = createQueryMapper(stateHashToRisonState); -export const hashQuery = createQueryMapper(risonStateToStateHash); +export const unhashQuery = createQueryMapper(hashedStateToExpandedState); +export const hashQuery = createQueryMapper(expandedStateToHashedState); export const unhashUrl = createQueryReplacer(unhashQuery); export const hashUrl = createQueryReplacer(hashQuery); @@ -61,97 +56,5 @@ function createQueryReplacer( queryMapper: (q: IParsedUrlQuery, options?: IUrlQueryMapperOptions) => IParsedUrlQuery, options?: IUrlQueryReplacerOptions ) { - return (url: string) => { - if (!url) return url; - - const parsedUrl = parseUrl(url, true); - if (!parsedUrl.hash) return url; - - const appUrl = parsedUrl.hash.slice(1); // trim the # - if (!appUrl) return url; - - const appUrlParsed = parseUrl(appUrl, true); - if (!appUrlParsed.query) return url; - - const changedAppQuery = queryMapper(appUrlParsed.query, options); - - // encodeUriQuery implements the less-aggressive encoding done naturally by - // the browser. We use it to generate the same urls the browser would - const changedAppQueryString = stringifyQueryString(changedAppQuery, undefined, undefined, { - encodeURIComponent: encodeUriQuery, - }); - - return formatUrl({ - ...parsedUrl, - hash: formatUrl({ - pathname: appUrlParsed.pathname, - search: changedAppQueryString, - }), - }); - }; -} - -// TODO: this helper should be merged with or replaced by -// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts -// maybe to become simplified stateless version -export function retrieveState(stateHash: string): RisonObject { - const json = hashedItemStore.getItem(stateHash); - const throwUnableToRestoreUrlError = () => { - throw new Error( - i18n.translate('kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage', { - defaultMessage: - 'Unable to completely restore the URL, be sure to use the share functionality.', - }) - ); - }; - if (json === null) { - return throwUnableToRestoreUrlError(); - } - try { - return JSON.parse(json); - } catch (e) { - return throwUnableToRestoreUrlError(); - } -} - -// TODO: this helper should be merged with or replaced by -// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts -// maybe to become simplified stateless version -export function persistState(state: RisonObject): string { - const json = JSON.stringify(state); - const hash = createStateHash(json); - - const isItemSet = hashedItemStore.setItem(hash, json); - if (isItemSet) return hash; - // If we ran out of space trying to persist the state, notify the user. - const message = i18n.translate( - 'kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage', - { - defaultMessage: - 'Kibana is unable to store history items in your session ' + - `because it is full and there don't seem to be items any items safe ` + - 'to delete.\n\n' + - 'This can usually be fixed by moving to a fresh tab, but could ' + - 'be caused by a larger issue. If you are seeing this message regularly, ' + - 'please file an issue at {gitHubIssuesUrl}.', - values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' }, - } - ); - throw new Error(message); -} - -function stateHashToRisonState(stateHashOrRison: string): string { - if (isStateHash(stateHashOrRison)) { - return rison.encode(retrieveState(stateHashOrRison)); - } - - return stateHashOrRison; -} - -function risonStateToStateHash(stateHashOrRison: string): string | null { - if (isStateHash(stateHashOrRison)) { - return stateHashOrRison; - } - - return persistState(rison.decode(stateHashOrRison) as RisonObject); + return (url: string) => replaceUrlHashQuery(url, query => queryMapper(query, options)); } diff --git a/src/plugins/kibana_utils/public/state_management/url/index.ts b/src/plugins/kibana_utils/public/state_management/url/index.ts index 30c5696233db73..40491bf7a274bf 100644 --- a/src/plugins/kibana_utils/public/state_management/url/index.ts +++ b/src/plugins/kibana_utils/public/state_management/url/index.ts @@ -17,4 +17,12 @@ * under the License. */ -export * from './hash_unhash_url'; +export { hashUrl, hashQuery, unhashUrl, unhashQuery } from './hash_unhash_url'; +export { + createKbnUrlControls, + setStateToKbnUrl, + getStateFromKbnUrl, + getStatesFromKbnUrl, + IKbnUrlControls, +} from './kbn_url_storage'; +export { createUrlTracker } from './url_tracker'; diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts new file mode 100644 index 00000000000000..f1c527d3d53097 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts @@ -0,0 +1,246 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import '../../storage/hashed_item_store/mock'; +import { + History, + createBrowserHistory, + createHashHistory, + createMemoryHistory, + createPath, +} from 'history'; +import { + getRelativeToHistoryPath, + createKbnUrlControls, + IKbnUrlControls, + setStateToKbnUrl, + getStateFromKbnUrl, +} from './kbn_url_storage'; + +describe('kbn_url_storage', () => { + describe('getStateFromUrl & setStateToUrl', () => { + const url = 'http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id'; + const state1 = { + testStr: '123', + testNumber: 0, + testObj: { test: '123' }, + testNull: null, + testArray: [1, 2, {}], + }; + const state2 = { + test: '123', + }; + + it('should set expanded state to url', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: false }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: false }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(test:'123')"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState2).toEqual(state2); + }); + + it('should set hashed state to url', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@a897fac"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: true }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@40f94d5"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState2).toEqual(state2); + }); + }); + + describe('urlControls', () => { + let history: History; + let urlControls: IKbnUrlControls; + beforeEach(() => { + history = createMemoryHistory(); + urlControls = createKbnUrlControls(history); + }); + + const getCurrentUrl = () => createPath(history.location); + it('should update url', () => { + urlControls.update('/1', false); + + expect(getCurrentUrl()).toBe('/1'); + expect(history.length).toBe(2); + + urlControls.update('/2', true); + + expect(getCurrentUrl()).toBe('/2'); + expect(history.length).toBe(2); + }); + + it('should update url async', async () => { + const pr1 = urlControls.updateAsync(() => '/1', false); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', false); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + }); + + it('should push url state if at least 1 push in async chain', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(2); + }); + + it('should replace url state if all updates in async chain are replace', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', true); + const pr3 = urlControls.updateAsync(() => '/3', true); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(1); + }); + + it('should listen for url updates', async () => { + const cb = jest.fn(); + urlControls.listen(cb); + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', true); + const pr3 = urlControls.updateAsync(() => '/3', true); + await Promise.all([pr1, pr2, pr3]); + + urlControls.update('/4', false); + urlControls.update('/5', true); + + expect(cb).toHaveBeenCalledTimes(3); + }); + + it('should flush async url updates', async () => { + const pr1 = urlControls.updateAsync(() => '/1', false); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', false); + expect(getCurrentUrl()).toBe('/'); + urlControls.flush(); + expect(getCurrentUrl()).toBe('/3'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + }); + + it('flush should take priority over regular replace behaviour', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + urlControls.flush(false); + expect(getCurrentUrl()).toBe('/3'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(2); + }); + + it('should cancel async url updates', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + urlControls.cancel(); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/'); + }); + }); + + describe('getRelativeToHistoryPath', () => { + it('should extract path relative to browser history without basename', () => { + const history = createBrowserHistory(); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to browser history with basename', () => { + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const history1 = createBrowserHistory({ basename: '/oxf/app/' }); + const relativePath1 = getRelativeToHistoryPath(url, history1); + expect(relativePath1).toEqual( + "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + + const history2 = createBrowserHistory({ basename: '/oxf/app/kibana/' }); + const relativePath2 = getRelativeToHistoryPath(url, history2); + expect(relativePath2).toEqual( + "#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to browser history with basename from relative url', () => { + const history = createBrowserHistory({ basename: '/oxf/app/' }); + const url = + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history without basename', () => { + const history = createHashHistory(); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history with basename', () => { + const history = createHashHistory({ basename: 'management' }); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history with basename from relative url', () => { + const history = createHashHistory({ basename: 'management' }); + const url = + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts new file mode 100644 index 00000000000000..03c136ea3d0928 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts @@ -0,0 +1,235 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { format as formatUrl } from 'url'; +import { createBrowserHistory, History } from 'history'; +import { decodeState, encodeState } from '../state_encoder'; +import { getCurrentUrl, parseUrl, parseUrlHash } from './parse'; +import { stringifyQueryString } from './stringify_query_string'; +import { replaceUrlHashQuery } from './format'; + +/** + * Parses a kibana url and retrieves all the states encoded into url, + * Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage) + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * will return object: + * {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}}; + */ +export function getStatesFromKbnUrl( + url: string = window.location.href, + keys?: string[] +): Record { + const query = parseUrlHash(url)?.query; + + if (!query) return {}; + const decoded: Record = {}; + Object.entries(query) + .filter(([key]) => (keys ? keys.includes(key) : true)) + .forEach(([q, value]) => { + decoded[q] = decodeState(value as string); + }); + + return decoded; +} + +/** + * Retrieves specific state from url by key + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * and key '_a' + * will return object: + * {tab: 'indexedFields'} + */ +export function getStateFromKbnUrl( + key: string, + url: string = window.location.href +): State | null { + return (getStatesFromKbnUrl(url, [key])[key] as State) || null; +} + +/** + * Sets state to the url by key and returns a new url string. + * Doesn't actually updates history + * + * e.g.: + * given a url: http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * key: '_a' + * and state: {tab: 'other'} + * + * will return url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:other)&_b=(f:test,i:'',l:'') + */ +export function setStateToKbnUrl( + key: string, + state: State, + { useHash = false }: { useHash: boolean } = { useHash: false }, + rawUrl = window.location.href +): string { + return replaceUrlHashQuery(rawUrl, query => { + const encoded = encodeState(state, useHash); + return { + ...query, + [key]: encoded, + }; + }); +} + +/** + * A tiny wrapper around history library to listen for url changes and update url + * History library handles a bunch of cross browser edge cases + */ +export interface IKbnUrlControls { + /** + * Listen for url changes + * @param cb - get's called when url has been changed + */ + listen: (cb: () => void) => () => void; + + /** + * Updates url synchronously + * @param url - url to update to + * @param replace - use replace instead of push + */ + update: (url: string, replace: boolean) => string; + + /** + * Schedules url update to next microtask, + * Useful to batch sync changes to url to cause only one browser history update + * @param updater - fn which receives current url and should return next url to update to + * @param replace - use replace instead of push + */ + updateAsync: (updater: UrlUpdaterFnType, replace?: boolean) => Promise; + + /** + * Synchronously flushes scheduled url updates + * @param replace - if replace passed in, then uses it instead of push. Otherwise push or replace is picked depending on updateQueue + */ + flush: (replace?: boolean) => string; + + /** + * Cancels any pending url updates + */ + cancel: () => void; +} +export type UrlUpdaterFnType = (currentUrl: string) => string; + +export const createKbnUrlControls = ( + history: History = createBrowserHistory() +): IKbnUrlControls => { + const updateQueue: Array<(currentUrl: string) => string> = []; + + // if we should replace or push with next async update, + // if any call in a queue asked to push, then we should push + let shouldReplace = true; + + function updateUrl(newUrl: string, replace = false): string { + const currentUrl = getCurrentUrl(); + if (newUrl === currentUrl) return currentUrl; // skip update + + const historyPath = getRelativeToHistoryPath(newUrl, history); + + if (replace) { + history.replace(historyPath); + } else { + history.push(historyPath); + } + + return getCurrentUrl(); + } + + // queue clean up + function cleanUp() { + updateQueue.splice(0, updateQueue.length); + shouldReplace = true; + } + + // runs scheduled url updates + function flush(replace = shouldReplace) { + if (updateQueue.length === 0) return getCurrentUrl(); + const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl()); + + cleanUp(); + + const newUrl = updateUrl(resultUrl, replace); + return newUrl; + } + + return { + listen: (cb: () => void) => + history.listen(() => { + cb(); + }), + update: (newUrl: string, replace = false) => updateUrl(newUrl, replace), + updateAsync: (updater: (currentUrl: string) => string, replace = false) => { + updateQueue.push(updater); + if (shouldReplace) { + shouldReplace = replace; + } + + // Schedule url update to the next microtask + // this allows to batch synchronous url changes + return Promise.resolve().then(() => { + return flush(); + }); + }, + flush: (replace?: boolean) => { + return flush(replace); + }, + cancel: () => { + cleanUp(); + }, + }; +}; + +/** + * Depending on history configuration extracts relative path for history updates + * 4 possible cases (see tests): + * 1. Browser history with empty base path + * 2. Browser history with base path + * 3. Hash history with empty base path + * 4. Hash history with base path + */ +export function getRelativeToHistoryPath(absoluteUrl: string, history: History): History.Path { + function stripBasename(path: string = '') { + const stripLeadingHash = (_: string) => (_.charAt(0) === '#' ? _.substr(1) : _); + const stripTrailingSlash = (_: string) => + _.charAt(_.length - 1) === '/' ? _.substr(0, _.length - 1) : _; + const baseName = stripLeadingHash(stripTrailingSlash(history.createHref({}))); + return path.startsWith(baseName) ? path.substr(baseName.length) : path; + } + const isHashHistory = history.createHref({}).includes('#'); + const parsedUrl = isHashHistory ? parseUrlHash(absoluteUrl)! : parseUrl(absoluteUrl); + const parsedHash = isHashHistory ? null : parseUrlHash(absoluteUrl); + + return formatUrl({ + pathname: stripBasename(parsedUrl.pathname), + search: stringifyQueryString(parsedUrl.query), + hash: parsedHash + ? formatUrl({ + pathname: parsedHash.pathname, + search: stringifyQueryString(parsedHash.query), + }) + : parsedUrl.hash, + }); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.test.ts b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts new file mode 100644 index 00000000000000..774f18b7345142 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parseUrlHash } from './parse'; + +describe('parseUrlHash', () => { + it('should return null if no hash', () => { + expect(parseUrlHash('http://localhost:5601/oxf/app/kibana')).toBeNull(); + }); + + it('should return parsed hash', () => { + expect(parseUrlHash('http://localhost:5601/oxf/app/kibana/#/path?test=test')).toMatchObject({ + pathname: '/path', + query: { + test: 'test', + }, + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.ts b/src/plugins/kibana_utils/public/state_management/url/parse.ts new file mode 100644 index 00000000000000..95041d0662f56d --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/parse.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parse as _parseUrl } from 'url'; + +export const parseUrl = (url: string) => _parseUrl(url, true); +export const parseUrlHash = (url: string) => { + const hash = parseUrl(url).hash; + return hash ? parseUrl(hash.slice(1)) : null; +}; +export const getCurrentUrl = () => window.location.href; +export const parseCurrentUrl = () => parseUrl(getCurrentUrl()); +export const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl()); diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts new file mode 100644 index 00000000000000..3ca6cb42146829 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { encodeUriQuery, stringifyQueryString } from './stringify_query_string'; + +describe('stringifyQueryString', () => { + it('stringifyQueryString', () => { + expect( + stringifyQueryString({ + a: 'asdf1234asdf', + b: "-_.!~*'() -_.!~*'()", + c: ':@$, :@$,', + d: "&;=+# &;=+#'", + f: ' ', + g: 'null', + }) + ).toMatchInlineSnapshot( + `"a=asdf1234asdf&b=-_.!~*'()%20-_.!~*'()&c=:@$,%20:@$,&d=%26;%3D%2B%23%20%26;%3D%2B%23'&f=%20&g=null"` + ); + }); +}); + +describe('encodeUriQuery', function() { + it('should correctly encode uri query and not encode chars defined as pchar set in rfc3986', () => { + // don't encode alphanum + expect(encodeUriQuery('asdf1234asdf')).toBe('asdf1234asdf'); + + // don't encode unreserved + expect(encodeUriQuery("-_.!~*'() -_.!~*'()")).toBe("-_.!~*'()+-_.!~*'()"); + + // don't encode the rest of pchar + expect(encodeUriQuery(':@$, :@$,')).toBe(':@$,+:@$,'); + + // encode '&', ';', '=', '+', and '#' + expect(encodeUriQuery('&;=+# &;=+#')).toBe('%26;%3D%2B%23+%26;%3D%2B%23'); + + // encode ' ' as '+' + expect(encodeUriQuery(' ')).toBe('++'); + + // encode ' ' as '%20' when a flag is used + expect(encodeUriQuery(' ', true)).toBe('%20%20'); + + // do not encode `null` as '+' when flag is used + expect(encodeUriQuery('null', true)).toBe('null'); + + // do not encode `null` with no flag + expect(encodeUriQuery('null')).toBe('null'); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts new file mode 100644 index 00000000000000..e951dfac29c025 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { stringify, ParsedUrlQuery } from 'querystring'; + +// encodeUriQuery implements the less-aggressive encoding done naturally by +// the browser. We use it to generate the same urls the browser would +export const stringifyQueryString = (query: ParsedUrlQuery) => + stringify(query, undefined, undefined, { + // encode spaces with %20 is needed to produce the same queries as angular does + // https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1377 + encodeURIComponent: (val: string) => encodeUriQuery(val, true), + }); + +/** + * Extracted from angular.js + * repo: https://github.com/angular/angular.js + * license: MIT - https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/LICENSE + * source: https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1413-L1432 + */ + +/** + * This method is intended for encoding *key* or *value* parts of query component. We need a custom + * method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be + * encoded per http://tools.ietf.org/html/rfc3986: + * query = *( pchar / "/" / "?" ) + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * pct-encoded = "%" HEXDIG HEXDIG + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ +export function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) { + return encodeURIComponent(val) + .replace(/%40/gi, '@') + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%3B/gi, ';') + .replace(/%20/g, pctEncodeSpaces ? '%20' : '+'); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts new file mode 100644 index 00000000000000..d7e5f99ffb700c --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createUrlTracker, IUrlTracker } from './url_tracker'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createMemoryHistory, History } from 'history'; + +describe('urlTracker', () => { + let storage: StubBrowserStorage; + let history: History; + let urlTracker: IUrlTracker; + beforeEach(() => { + storage = new StubBrowserStorage(); + history = createMemoryHistory(); + urlTracker = createUrlTracker('test', storage); + }); + + it('should return null if no tracked url', () => { + expect(urlTracker.getTrackedUrl()).toBeNull(); + }); + + it('should return last tracked url', () => { + urlTracker.trackUrl('http://localhost:4200'); + urlTracker.trackUrl('http://localhost:4201'); + urlTracker.trackUrl('http://localhost:4202'); + expect(urlTracker.getTrackedUrl()).toBe('http://localhost:4202'); + }); + + it('should listen to history and track updates', () => { + const stop = urlTracker.startTrackingUrl(history); + expect(urlTracker.getTrackedUrl()).toBe('/'); + history.push('/1'); + history.replace('/2'); + expect(urlTracker.getTrackedUrl()).toBe('/2'); + + stop(); + history.replace('/3'); + expect(urlTracker.getTrackedUrl()).toBe('/2'); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts new file mode 100644 index 00000000000000..89e72e94ba6b46 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createBrowserHistory, History, Location } from 'history'; +import { getRelativeToHistoryPath } from './kbn_url_storage'; + +export interface IUrlTracker { + startTrackingUrl: (history?: History) => () => void; + getTrackedUrl: () => string | null; + trackUrl: (url: string) => void; +} +/** + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage so it could be restored if navigated back to the app + */ +export function createUrlTracker(key: string, storage: Storage = sessionStorage): IUrlTracker { + return { + startTrackingUrl(history: History = createBrowserHistory()) { + const track = (location: Location) => { + const url = getRelativeToHistoryPath(history.createHref(location), history); + storage.setItem(key, url); + }; + track(history.location); + return history.listen(track); + }, + getTrackedUrl() { + return storage.getItem(key); + }, + trackUrl(url: string) { + storage.setItem(key, url); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/index.ts b/src/plugins/kibana_utils/public/state_sync/index.ts new file mode 100644 index 00000000000000..1dfa998c5bb9d9 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/index.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + createSessionStorageStateStorage, + createKbnUrlStateStorage, + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from './state_sync_state_storage'; +export { IStateSyncConfig, INullableBaseStateContainer } from './types'; +export { + syncState, + syncStates, + StopSyncStateFnType, + StartSyncStateFnType, + ISyncStateRef, +} from './state_sync'; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts new file mode 100644 index 00000000000000..cc513bc674d0fc --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -0,0 +1,308 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BaseStateContainer, createStateContainer } from '../state_containers'; +import { + defaultState, + pureTransitions, + TodoActions, + TodoState, +} from '../../demos/state_containers/todomvc'; +import { syncState, syncStates } from './state_sync'; +import { IStateStorage } from './state_sync_state_storage/types'; +import { Observable, Subject } from 'rxjs'; +import { + createSessionStorageStateStorage, + createKbnUrlStateStorage, + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from './state_sync_state_storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createBrowserHistory, History } from 'history'; +import { INullableBaseStateContainer } from './types'; + +describe('state_sync', () => { + describe('basic', () => { + const container = createStateContainer(defaultState, pureTransitions); + beforeEach(() => { + container.set(defaultState); + }); + const storageChange$ = new Subject(); + let testStateStorage: IStateStorage; + + beforeEach(() => { + testStateStorage = { + set: jest.fn(), + get: jest.fn(), + change$: (key: string) => storageChange$.asObservable() as Observable, + }; + }); + + it('should sync state to storage', () => { + const key = '_s'; + const { start, stop } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + // initial sync of state to storage is not happening + expect(testStateStorage.set).not.toBeCalled(); + + container.transitions.add({ + id: 1, + text: 'Learning transitions...', + completed: false, + }); + expect(testStateStorage.set).toBeCalledWith(key, container.getState()); + stop(); + }); + + it('should sync storage to state', () => { + const key = '_s'; + const storageState1 = [{ id: 1, text: 'todo', completed: false }]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState1); + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + // initial sync of storage to state is not happening + expect(container.getState()).toEqual(defaultState); + + const storageState2 = [{ id: 1, text: 'todo', completed: true }]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); + storageChange$.next(storageState2); + + expect(container.getState()).toEqual(storageState2); + + stop(); + }); + + it('should not update storage if no actual state change happened', () => { + const key = '_s'; + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + (testStateStorage.set as jest.Mock).mockClear(); + + container.set(defaultState); + expect(testStateStorage.set).not.toBeCalled(); + + stop(); + }); + + it('should not update state container if no actual storage change happened', () => { + const key = '_s'; + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + const originalState = container.getState(); + const storageState = [...originalState]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState); + storageChange$.next(storageState); + + expect(container.getState()).toBe(originalState); + + stop(); + }); + + it('storage change to null should notify state', () => { + container.set([{ completed: false, id: 1, text: 'changed' }]); + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: '_s', + stateStorage: testStateStorage, + }, + ]); + start(); + + (testStateStorage.get as jest.Mock).mockImplementation(() => null); + storageChange$.next(null); + + expect(container.getState()).toEqual(defaultState); + + stop(); + }); + }); + + describe('integration', () => { + const key = '_s'; + const container = createStateContainer(defaultState, pureTransitions); + + let sessionStorage: StubBrowserStorage; + let sessionStorageSyncStrategy: ISessionStorageStateStorage; + let history: History; + let urlSyncStrategy: IKbnUrlStateStorage; + const getCurrentUrl = () => history.createHref(history.location); + const tick = () => new Promise(resolve => setTimeout(resolve)); + + beforeEach(() => { + container.set(defaultState); + + window.location.href = '/'; + sessionStorage = new StubBrowserStorage(); + sessionStorageSyncStrategy = createSessionStorageStateStorage(sessionStorage); + history = createBrowserHistory(); + urlSyncStrategy = createKbnUrlStateStorage({ useHash: false, history }); + }); + + it('change to one storage should also update other storage', () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: sessionStorageSyncStrategy, + }, + ]); + start(); + + const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; + history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); + + expect(container.getState()).toEqual(newStateFromUrl); + expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); + + stop(); + }); + + it('KbnUrlSyncStrategy applies url updates asynchronously to trigger single history change', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + await tick(); + expect(history.length).toBe(startHistoryLength + 1); + + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + stop(); + }); + + it('KbnUrlSyncStrategy supports flushing url updates synchronously and triggers single history change', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + urlSyncStrategy.flush(); + + expect(history.length).toBe(startHistoryLength + 1); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + await tick(); + + expect(history.length).toBe(startHistoryLength + 1); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + stop(); + }); + + it('KbnUrlSyncStrategy supports cancellation of pending updates ', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + urlSyncStrategy.cancel(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + await tick(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + stop(); + }); + }); +}); + +function withDefaultState( + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + stateContainer.set(state || defaultState); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts new file mode 100644 index 00000000000000..f0ef1423dec71b --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -0,0 +1,171 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EMPTY, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import defaultComparator from 'fast-deep-equal'; +import { IStateSyncConfig } from './types'; +import { IStateStorage } from './state_sync_state_storage'; +import { distinctUntilChangedWithInitialValue } from '../../common'; + +/** + * Utility for syncing application state wrapped in state container + * with some kind of storage (e.g. URL) + * + * Examples: + * + * 1. the simplest use case + * const stateStorage = createKbnUrlStateStorage(); + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage + * }); + * + * 2. conditionally configuring sync strategy + * const stateStorage = createKbnUrlStateStorage({useHash: config.get('state:stateContainerInSessionStorage')}) + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage + * }); + * + * 3. implementing custom sync strategy + * const localStorageStateStorage = { + * set: (storageKey, state) => localStorage.setItem(storageKey, JSON.stringify(state)), + * get: (storageKey) => localStorage.getItem(storageKey) ? JSON.parse(localStorage.getItem(storageKey)) : null + * }; + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage: localStorageStateStorage + * }); + * + * 4. Transform state before serialising + * Useful for: + * * Migration / backward compatibility + * * Syncing part of state + * * Providing default values + * const stateToStorage = (s) => ({ tab: s.tab }); + * syncState({ + * storageKey: '_s', + * stateContainer: { + * get: () => stateToStorage(stateContainer.get()), + * set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }), + * state$: stateContainer.state$.pipe(map(stateToStorage)) + * }, + * stateStorage + * }); + * + * Caveats: + * + * 1. It is responsibility of consumer to make sure that initial app state and storage are in sync before starting syncing + * No initial sync happens when syncState() is called + */ +export type StopSyncStateFnType = () => void; +export type StartSyncStateFnType = () => void; +export interface ISyncStateRef { + // stop syncing state with storage + stop: StopSyncStateFnType; + // start syncing state with storage + start: StartSyncStateFnType; +} +export function syncState({ + storageKey, + stateStorage, + stateContainer, +}: IStateSyncConfig): ISyncStateRef { + const subscriptions: Subscription[] = []; + + const updateState = () => { + const newState = stateStorage.get(storageKey); + const oldState = stateContainer.get(); + if (!defaultComparator(newState, oldState)) { + stateContainer.set(newState); + } + }; + + const updateStorage = () => { + const newStorageState = stateContainer.get(); + const oldStorageState = stateStorage.get(storageKey); + if (!defaultComparator(newStorageState, oldStorageState)) { + stateStorage.set(storageKey, newStorageState); + } + }; + + const onStateChange$ = stateContainer.state$.pipe( + distinctUntilChangedWithInitialValue(stateContainer.get(), defaultComparator), + tap(() => updateStorage()) + ); + + const onStorageChange$ = stateStorage.change$ + ? stateStorage.change$(storageKey).pipe( + distinctUntilChangedWithInitialValue(stateStorage.get(storageKey), defaultComparator), + tap(() => { + updateState(); + }) + ) + : EMPTY; + + return { + stop: () => { + // if stateStorage has any cancellation logic, then run it + if (stateStorage.cancel) { + stateStorage.cancel(); + } + + subscriptions.forEach(s => s.unsubscribe()); + subscriptions.splice(0, subscriptions.length); + }, + start: () => { + if (subscriptions.length > 0) { + throw new Error("syncState: can't start syncing state, when syncing is in progress"); + } + subscriptions.push(onStateChange$.subscribe(), onStorageChange$.subscribe()); + }, + }; +} + +/** + * multiple different sync configs + * syncStates([ + * { + * storageKey: '_s1', + * stateStorage: stateStorage1, + * stateContainer: stateContainer1, + * }, + * { + * storageKey: '_s2', + * stateStorage: stateStorage2, + * stateContainer: stateContainer2, + * }, + * ]); + * @param stateSyncConfigs - Array of IStateSyncConfig to sync + */ +export function syncStates(stateSyncConfigs: Array>): ISyncStateRef { + const syncRefs = stateSyncConfigs.map(config => syncState(config)); + return { + stop: () => { + syncRefs.forEach(s => s.stop()); + }, + start: () => { + syncRefs.forEach(s => s.start()); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts new file mode 100644 index 00000000000000..826122176e061e --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts @@ -0,0 +1,120 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import '../../storage/hashed_item_store/mock'; +import { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage'; +import { History, createBrowserHistory } from 'history'; +import { takeUntil, toArray } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +describe('KbnUrlStateStorage', () => { + describe('useHash: false', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should flush state to url', () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.flush(); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should cancel url updates', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + const pr = urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.cancel(); + await pr; + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + expect(urlStateStorage.get(key)).toEqual(null); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key) + .pipe(takeUntil(destroy$), toArray()) + .toPromise(); + + history.push(`/#?${key}=(ok:1,test:test)`); + history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test#?some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + }); + + describe('useHash: true', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: true, history }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=h@487e077"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key) + .pipe(takeUntil(destroy$), toArray()) + .toPromise(); + + history.push(`/#?${key}=(ok:1,test:test)`); + history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test#?some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts new file mode 100644 index 00000000000000..245006349ad55b --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { History } from 'history'; +import { IStateStorage } from './types'; +import { + createKbnUrlControls, + getStateFromKbnUrl, + setStateToKbnUrl, +} from '../../state_management/url'; + +export interface IKbnUrlStateStorage extends IStateStorage { + set: (key: string, state: State, opts?: { replace: boolean }) => Promise; + get: (key: string) => State | null; + change$: (key: string) => Observable; + + // cancels any pending url updates + cancel: () => void; + + // synchronously runs any pending url updates + flush: (opts?: { replace?: boolean }) => void; +} + +/** + * Implements syncing to/from url strategies. + * Replicates what was implemented in state (AppState, GlobalState) + * Both expanded and hashed use cases + */ +export const createKbnUrlStateStorage = ( + { useHash = false, history }: { useHash: boolean; history?: History } = { useHash: false } +): IKbnUrlStateStorage => { + const url = createKbnUrlControls(history); + return { + set: ( + key: string, + state: State, + { replace = false }: { replace: boolean } = { replace: false } + ) => { + // syncState() utils doesn't wait for this promise + return url.updateAsync( + currentUrl => setStateToKbnUrl(key, state, { useHash }, currentUrl), + replace + ); + }, + get: key => getStateFromKbnUrl(key), + change$: (key: string) => + new Observable(observer => { + const unlisten = url.listen(() => { + observer.next(); + }); + + return () => { + unlisten(); + }; + }).pipe( + map(() => getStateFromKbnUrl(key)), + share() + ), + flush: ({ replace = false }: { replace?: boolean } = {}) => { + url.flush(replace); + }, + cancel() { + url.cancel(); + }, + }; +}; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts new file mode 100644 index 00000000000000..f69629e7550088 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createSessionStorageStateStorage, + ISessionStorageStateStorage, +} from './create_session_storage_state_storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; + +describe('SessionStorageStateStorage', () => { + let browserStorage: StubBrowserStorage; + let stateStorage: ISessionStorageStateStorage; + beforeEach(() => { + browserStorage = new StubBrowserStorage(); + stateStorage = createSessionStorageStateStorage(browserStorage); + }); + + it('should synchronously sync to storage', () => { + const state = { state: 'state' }; + stateStorage.set('key', state); + expect(stateStorage.get('key')).toEqual(state); + expect(browserStorage.getItem('key')).not.toBeNull(); + }); + + it('should not implement change$', () => { + expect(stateStorage.change$).not.toBeDefined(); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts new file mode 100644 index 00000000000000..00edfdfd1ed61e --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IStateStorage } from './types'; + +export interface ISessionStorageStateStorage extends IStateStorage { + set: (key: string, state: State) => void; + get: (key: string) => State | null; +} + +export const createSessionStorageStateStorage = ( + storage: Storage = window.sessionStorage +): ISessionStorageStateStorage => { + return { + set: (key: string, state: State) => storage.setItem(key, JSON.stringify(state)), + get: (key: string) => JSON.parse(storage.getItem(key)!), + }; +}; diff --git a/test/typings/encode_uri_query.d.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts similarity index 75% rename from test/typings/encode_uri_query.d.ts rename to src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts index 4bfc5546244463..fe04333e5ef159 100644 --- a/test/typings/encode_uri_query.d.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts @@ -17,8 +17,9 @@ * under the License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { IStateStorage } from './types'; +export { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage'; +export { + createSessionStorageStateStorage, + ISessionStorageStateStorage, +} from './create_session_storage_state_storage'; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts new file mode 100644 index 00000000000000..add1dc259be458 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from 'rxjs'; + +/** + * Any StateStorage have to implement IStateStorage interface + * StateStorage is responsible for: + * * state serialisation / deserialization + * * persisting to and retrieving from storage + * + * For an example take a look at already implemented KbnUrl state storage + */ +export interface IStateStorage { + /** + * Take in a state object, should serialise and persist + */ + set: (key: string, state: State) => any; + + /** + * Should retrieve state from the storage and deserialize it + */ + get: (key: string) => State | null; + + /** + * Should notify when the stored state has changed + */ + change$?: (key: string) => Observable; + + /** + * Optional method to cancel any pending activity + * syncState() will call it, if it is provided by IStateStorage + */ + cancel?: () => void; +} diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts new file mode 100644 index 00000000000000..0f7395ad0f0e56 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/types.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BaseStateContainer } from '../state_containers/types'; +import { IStateStorage } from './state_sync_state_storage'; + +export interface INullableBaseStateContainer extends BaseStateContainer { + // State container for stateSync() have to accept "null" + // for example, set() implementation could handle null and fallback to some default state + // this is required to handle edge case, when state in storage becomes empty and syncing is in progress. + // state container will be notified about about storage becoming empty with null passed in + set: (state: State | null) => void; +} + +export interface IStateSyncConfig< + State = unknown, + StateStorage extends IStateStorage = IStateStorage +> { + /** + * Storage key to use for syncing, + * e.g. storageKey '_a' should sync state to ?_a query param + */ + storageKey: string; + /** + * State container to keep in sync with storage, have to implement INullableBaseStateContainer interface + * The idea is that ./state_containers/ should be used as a state container, + * but it is also possible to implement own custom container for advanced use cases + */ + stateContainer: INullableBaseStateContainer; + /** + * State storage to use, + * State storage is responsible for serialising / deserialising and persisting / retrieving stored state + * + * There are common strategies already implemented: + * './state_sync_state_storage/' + * which replicate what State (AppState, GlobalState) in legacy world did + * + */ + stateStorage: StateStorage; +} diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index 755a387afbd051..80135f1bfb6c84 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -3,5 +3,5 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [] + "requiredPlugins": ["kibana_legacy"] } diff --git a/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap b/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap new file mode 100644 index 00000000000000..7f13472ee02eed --- /dev/null +++ b/src/plugins/management/public/__snapshots__/management_app.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Management app can mount and unmount 1`] = ` +
+
+ Test App - Hello world! +
+
+`; + +exports[`Management app can mount and unmount 2`] = `
`; diff --git a/src/plugins/management/public/components/_index.scss b/src/plugins/management/public/components/_index.scss new file mode 100644 index 00000000000000..df0ebb48803d97 --- /dev/null +++ b/src/plugins/management/public/components/_index.scss @@ -0,0 +1 @@ +@import './management_sidebar_nav/index'; diff --git a/src/core/server/elasticsearch/elasticsearch_client_config.test.mocks.ts b/src/plugins/management/public/components/index.ts similarity index 86% rename from src/core/server/elasticsearch/elasticsearch_client_config.test.mocks.ts rename to src/plugins/management/public/components/index.ts index f6c6079822cb5e..2650d23d3c25ca 100644 --- a/src/core/server/elasticsearch/elasticsearch_client_config.test.mocks.ts +++ b/src/plugins/management/public/components/index.ts @@ -17,5 +17,5 @@ * under the License. */ -export const mockReadFileSync = jest.fn(); -jest.mock('fs', () => ({ readFileSync: mockReadFileSync })); +export { ManagementSidebarNav } from './management_sidebar_nav'; +export { ManagementChrome } from './management_chrome'; diff --git a/webpackShims/angular.js b/src/plugins/management/public/components/management_chrome/index.ts similarity index 89% rename from webpackShims/angular.js rename to src/plugins/management/public/components/management_chrome/index.ts index 4857f0f8975bca..b82c1af871be71 100644 --- a/webpackShims/angular.js +++ b/src/plugins/management/public/components/management_chrome/index.ts @@ -17,6 +17,4 @@ * under the License. */ -require('jquery'); -require('../node_modules/angular/angular'); -module.exports = window.angular; +export { ManagementChrome } from './management_chrome'; diff --git a/src/plugins/management/public/components/management_chrome/management_chrome.tsx b/src/plugins/management/public/components/management_chrome/management_chrome.tsx new file mode 100644 index 00000000000000..df844e22089365 --- /dev/null +++ b/src/plugins/management/public/components/management_chrome/management_chrome.tsx @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiPage, EuiPageBody, EuiPageSideBar } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ManagementSidebarNav } from '../management_sidebar_nav'; +import { LegacySection } from '../../types'; +import { ManagementSection } from '../../management_section'; + +interface Props { + getSections: () => ManagementSection[]; + legacySections: LegacySection[]; + selectedId: string; + onMounted: (element: HTMLDivElement) => void; +} + +export class ManagementChrome extends React.Component { + private container = React.createRef(); + componentDidMount() { + if (this.container.current) { + this.props.onMounted(this.container.current); + } + } + render() { + return ( + + + + + + +
+ + + + ); + } +} diff --git a/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap b/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap new file mode 100644 index 00000000000000..e7225b356ed685 --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/__snapshots__/management_sidebar_nav.test.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Management adds legacy apps to existing SidebarNav sections 1`] = ` +Array [ + Object { + "data-test-subj": "activeSection", + "icon": null, + "id": "activeSection", + "items": Array [ + Object { + "data-test-subj": "item", + "href": undefined, + "id": "item", + "isSelected": false, + "name": "item", + "order": undefined, + }, + ], + "name": "activeSection", + "order": 10, + }, + Object { + "data-test-subj": "no-active-items", + "icon": null, + "id": "no-active-items", + "items": Array [ + Object { + "data-test-subj": "disabled", + "href": undefined, + "id": "disabled", + "isSelected": false, + "name": "disabled", + "order": undefined, + }, + Object { + "data-test-subj": "notVisible", + "href": undefined, + "id": "notVisible", + "isSelected": false, + "name": "notVisible", + "order": undefined, + }, + ], + "name": "No active items", + "order": 10, + }, +] +`; + +exports[`Management maps legacy sections and apps into SidebarNav items 1`] = ` +Array [ + Object { + "data-test-subj": "no-active-items", + "icon": null, + "id": "no-active-items", + "items": Array [ + Object { + "data-test-subj": "disabled", + "href": undefined, + "id": "disabled", + "isSelected": false, + "name": "disabled", + "order": undefined, + }, + Object { + "data-test-subj": "notVisible", + "href": undefined, + "id": "notVisible", + "isSelected": false, + "name": "notVisible", + "order": undefined, + }, + ], + "name": "No active items", + "order": 10, + }, + Object { + "data-test-subj": "activeSection", + "icon": null, + "id": "activeSection", + "items": Array [ + Object { + "data-test-subj": "item", + "href": undefined, + "id": "item", + "isSelected": false, + "name": "item", + "order": undefined, + }, + ], + "name": "activeSection", + "order": 10, + }, +] +`; diff --git a/src/legacy/ui/public/management/components/_index.scss b/src/plugins/management/public/components/management_sidebar_nav/_index.scss similarity index 100% rename from src/legacy/ui/public/management/components/_index.scss rename to src/plugins/management/public/components/management_sidebar_nav/_index.scss diff --git a/src/legacy/ui/public/management/components/_sidebar_nav.scss b/src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss similarity index 88% rename from src/legacy/ui/public/management/components/_sidebar_nav.scss rename to src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss index 0c2b2bc228b2cc..cf88ed9b0a88b5 100644 --- a/src/legacy/ui/public/management/components/_sidebar_nav.scss +++ b/src/plugins/management/public/components/management_sidebar_nav/_sidebar_nav.scss @@ -1,4 +1,4 @@ -.mgtSidebarNav { +.mgtSideBarNav { width: 192px; } diff --git a/src/plugins/management/public/components/management_sidebar_nav/index.ts b/src/plugins/management/public/components/management_sidebar_nav/index.ts new file mode 100644 index 00000000000000..79142fdb69a74d --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ManagementSidebarNav } from './management_sidebar_nav'; diff --git a/src/legacy/ui/public/management/components/sidebar_nav.test.ts b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts similarity index 75% rename from src/legacy/ui/public/management/components/sidebar_nav.test.ts rename to src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts index e02cc7d2901b64..e04e0a75726126 100644 --- a/src/legacy/ui/public/management/components/sidebar_nav.test.ts +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { IndexedArray } from '../../indexed_array'; -import { sideNavItems } from '../components/sidebar_nav'; +import { IndexedArray } from '../../../../../legacy/ui/public/indexed_array'; +import { mergeLegacyItems } from './management_sidebar_nav'; const toIndexedArray = (initialSet: any[]) => new IndexedArray({ @@ -30,30 +30,33 @@ const toIndexedArray = (initialSet: any[]) => const activeProps = { visible: true, disabled: false }; const disabledProps = { visible: true, disabled: true }; const notVisibleProps = { visible: false, disabled: false }; - const visibleItem = { display: 'item', id: 'item', ...activeProps }; const notVisibleSection = { display: 'Not visible', id: 'not-visible', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...notVisibleProps, }; const disabledSection = { display: 'Disabled', id: 'disabled', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...disabledProps, }; const noItemsSection = { display: 'No items', id: 'no-items', + order: 10, visibleItems: toIndexedArray([]), ...activeProps, }; const noActiveItemsSection = { display: 'No active items', id: 'no-active-items', + order: 10, visibleItems: toIndexedArray([ { display: 'disabled', id: 'disabled', ...disabledProps }, { display: 'notVisible', id: 'notVisible', ...notVisibleProps }, @@ -63,6 +66,7 @@ const noActiveItemsSection = { const activeSection = { display: 'activeSection', id: 'activeSection', + order: 10, visibleItems: toIndexedArray([visibleItem]), ...activeProps, }; @@ -76,7 +80,19 @@ const managementSections = [ ]; describe('Management', () => { - it('filters and filters and maps section objects into SidebarNav items', () => { - expect(sideNavItems(managementSections, 'active-item-id')).toMatchSnapshot(); + it('maps legacy sections and apps into SidebarNav items', () => { + expect(mergeLegacyItems([], managementSections, 'active-item-id')).toMatchSnapshot(); + }); + + it('adds legacy apps to existing SidebarNav sections', () => { + const navSection = { + 'data-test-subj': 'activeSection', + icon: null, + id: 'activeSection', + items: [], + name: 'activeSection', + order: 10, + }; + expect(mergeLegacyItems([navSection], managementSections, 'active-item-id')).toMatchSnapshot(); }); }); diff --git a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx new file mode 100644 index 00000000000000..cb0b82d0f0bde4 --- /dev/null +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx @@ -0,0 +1,200 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + EuiIcon, + // @ts-ignore + EuiSideNav, + EuiScreenReaderOnly, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { LegacySection, LegacyApp } from '../../types'; +import { ManagementApp } from '../../management_app'; +import { ManagementSection } from '../../management_section'; + +interface NavApp { + id: string; + name: string; + [key: string]: unknown; + order: number; // only needed while merging platform and legacy +} + +interface NavSection extends NavApp { + items: NavApp[]; +} + +interface ManagementSidebarNavProps { + getSections: () => ManagementSection[]; + legacySections: LegacySection[]; + selectedId: string; +} + +interface ManagementSidebarNavState { + isSideNavOpenOnMobile: boolean; +} + +const managementSectionOrAppToNav = (appOrSection: ManagementApp | ManagementSection) => ({ + id: appOrSection.id, + name: appOrSection.title, + 'data-test-subj': appOrSection.id, + order: appOrSection.order, +}); + +const managementSectionToNavSection = (section: ManagementSection) => { + const iconType = section.euiIconType + ? section.euiIconType + : section.icon + ? section.icon + : 'empty'; + + return { + icon: , + ...managementSectionOrAppToNav(section), + }; +}; + +const managementAppToNavItem = (selectedId?: string, parentId?: string) => ( + app: ManagementApp +) => ({ + isSelected: selectedId === app.id, + href: `#/management/${parentId}/${app.id}`, + ...managementSectionOrAppToNav(app), +}); + +const legacySectionToNavSection = (section: LegacySection) => ({ + name: section.display, + id: section.id, + icon: section.icon ? : null, + items: [], + 'data-test-subj': section.id, + // @ts-ignore + order: section.order, +}); + +const legacyAppToNavItem = (app: LegacyApp, selectedId: string) => ({ + isSelected: selectedId === app.id, + name: app.display, + id: app.id, + href: app.url, + 'data-test-subj': app.id, + // @ts-ignore + order: app.order, +}); + +const sectionVisible = (section: LegacySection | LegacyApp) => !section.disabled && section.visible; + +const sideNavItems = (sections: ManagementSection[], selectedId: string) => + sections.map(section => ({ + items: section.getAppsEnabled().map(managementAppToNavItem(selectedId, section.id)), + ...managementSectionToNavSection(section), + })); + +const findOrAddSection = (navItems: NavSection[], legacySection: LegacySection): NavSection => { + const foundSection = navItems.find(sec => sec.id === legacySection.id); + + if (foundSection) { + return foundSection; + } else { + const newSection = legacySectionToNavSection(legacySection); + navItems.push(newSection); + navItems.sort((a: NavSection, b: NavSection) => a.order - b.order); // only needed while merging platform and legacy + return newSection; + } +}; + +export const mergeLegacyItems = ( + navItems: NavSection[], + legacySections: LegacySection[], + selectedId: string +) => { + const filteredLegacySections = legacySections + .filter(sectionVisible) + .filter(section => section.visibleItems.length); + + filteredLegacySections.forEach(legacySection => { + const section = findOrAddSection(navItems, legacySection); + legacySection.visibleItems.forEach(app => { + section.items.push(legacyAppToNavItem(app, selectedId)); + return section.items.sort((a, b) => a.order - b.order); + }); + }); + + return navItems; +}; + +const sectionsToItems = ( + sections: ManagementSection[], + legacySections: LegacySection[], + selectedId: string +) => { + const navItems = sideNavItems(sections, selectedId); + return mergeLegacyItems(navItems, legacySections, selectedId); +}; + +export class ManagementSidebarNav extends React.Component< + ManagementSidebarNavProps, + ManagementSidebarNavState +> { + constructor(props: ManagementSidebarNavProps) { + super(props); + this.state = { + isSideNavOpenOnMobile: false, + }; + } + + public render() { + const HEADER_ID = 'management-nav-header'; + + return ( + <> + +

+ {i18n.translate('management.nav.label', { + defaultMessage: 'Management', + })} +

+
+ + + ); + } + + private renderMobileTitle() { + return ; + } + + private toggleOpenOnMobile = () => { + this.setState({ + isSideNavOpenOnMobile: !this.state.isSideNavOpenOnMobile, + }); + }; +} diff --git a/src/plugins/management/public/index.ts b/src/plugins/management/public/index.ts index ee3866c734f194..faec466dbd6714 100644 --- a/src/plugins/management/public/index.ts +++ b/src/plugins/management/public/index.ts @@ -24,4 +24,7 @@ export function plugin(initializerContext: PluginInitializerContext) { return new ManagementPlugin(); } -export { ManagementStart } from './types'; +export { ManagementSetup, ManagementStart, RegisterManagementApp } from './types'; +export { ManagementApp } from './management_app'; +export { ManagementSection } from './management_section'; +export { ManagementSidebarNav } from './components'; // for use in legacy management apps diff --git a/src/plugins/management/public/legacy/index.js b/src/plugins/management/public/legacy/index.js index 63b9d2c6b27d7e..f2e0ba89b7b597 100644 --- a/src/plugins/management/public/legacy/index.js +++ b/src/plugins/management/public/legacy/index.js @@ -17,4 +17,5 @@ * under the License. */ -export { management } from './sections_register'; +export { LegacyManagementAdapter } from './sections_register'; +export { LegacyManagementSection } from './section'; diff --git a/src/plugins/management/public/legacy/section.js b/src/plugins/management/public/legacy/section.js index f269e3fe295b75..7d733b7b3173bf 100644 --- a/src/plugins/management/public/legacy/section.js +++ b/src/plugins/management/public/legacy/section.js @@ -22,7 +22,7 @@ import { IndexedArray } from '../../../../legacy/ui/public/indexed_array'; const listeners = []; -export class ManagementSection { +export class LegacyManagementSection { /** * @param {string} id * @param {object} options @@ -83,7 +83,11 @@ export class ManagementSection { */ register(id, options = {}) { - const item = new ManagementSection(id, assign(options, { parent: this }), this.capabilities); + const item = new LegacyManagementSection( + id, + assign(options, { parent: this }), + this.capabilities + ); if (this.hasItem(id)) { throw new Error(`'${id}' is already registered`); diff --git a/src/plugins/management/public/legacy/section.test.js b/src/plugins/management/public/legacy/section.test.js index 61bafd298afb32..45cc80ef80edd1 100644 --- a/src/plugins/management/public/legacy/section.test.js +++ b/src/plugins/management/public/legacy/section.test.js @@ -17,7 +17,7 @@ * under the License. */ -import { ManagementSection } from './section'; +import { LegacyManagementSection } from './section'; import { IndexedArray } from '../../../../legacy/ui/public/indexed_array'; const capabilitiesMock = { @@ -29,42 +29,42 @@ const capabilitiesMock = { describe('ManagementSection', () => { describe('constructor', () => { it('defaults display to id', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.display).toBe('kibana'); }); it('defaults visible to true', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.visible).toBe(true); }); it('defaults disabled to false', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.disabled).toBe(false); }); it('defaults tooltip to empty string', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.tooltip).toBe(''); }); it('defaults url to empty string', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.url).toBe(''); }); it('exposes items', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.items).toHaveLength(0); }); it('exposes visibleItems', () => { - const section = new ManagementSection('kibana', {}, capabilitiesMock); + const section = new LegacyManagementSection('kibana', {}, capabilitiesMock); expect(section.visibleItems).toHaveLength(0); }); it('assigns all options', () => { - const section = new ManagementSection( + const section = new LegacyManagementSection( 'kibana', { description: 'test', url: 'foobar' }, capabilitiesMock @@ -78,11 +78,11 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('returns a ManagementSection', () => { - expect(section.register('about')).toBeInstanceOf(ManagementSection); + expect(section.register('about')).toBeInstanceOf(LegacyManagementSection); }); it('provides a reference to the parent', () => { @@ -93,7 +93,7 @@ describe('ManagementSection', () => { section.register('about', { description: 'test' }); expect(section.items).toHaveLength(1); - expect(section.items[0]).toBeInstanceOf(ManagementSection); + expect(section.items[0]).toBeInstanceOf(LegacyManagementSection); expect(section.items[0].id).toBe('about'); }); @@ -126,7 +126,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('about'); }); @@ -157,12 +157,12 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('about'); }); it('returns registered section', () => { - expect(section.getSection('about')).toBeInstanceOf(ManagementSection); + expect(section.getSection('about')).toBeInstanceOf(LegacyManagementSection); }); it('returns undefined if un-registered', () => { @@ -171,7 +171,7 @@ describe('ManagementSection', () => { it('returns sub-sections specified via a /-separated path', () => { section.getSection('about').register('time'); - expect(section.getSection('about/time')).toBeInstanceOf(ManagementSection); + expect(section.getSection('about/time')).toBeInstanceOf(LegacyManagementSection); expect(section.getSection('about/time')).toBe(section.getSection('about').getSection('time')); }); @@ -184,7 +184,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('three', { order: 3 }); section.register('one', { order: 1 }); @@ -214,7 +214,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('hide sets visible to false', () => { @@ -233,7 +233,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); }); it('disable sets disabled to true', () => { @@ -251,7 +251,7 @@ describe('ManagementSection', () => { let section; beforeEach(() => { - section = new ManagementSection('kibana', {}, capabilitiesMock); + section = new LegacyManagementSection('kibana', {}, capabilitiesMock); section.register('three', { order: 3 }); section.register('one', { order: 1 }); diff --git a/src/plugins/management/public/legacy/sections_register.js b/src/plugins/management/public/legacy/sections_register.js index 888b2c5bc3aeb6..63d919377f89ea 100644 --- a/src/plugins/management/public/legacy/sections_register.js +++ b/src/plugins/management/public/legacy/sections_register.js @@ -17,44 +17,48 @@ * under the License. */ -import { ManagementSection } from './section'; +import { LegacyManagementSection } from './section'; import { i18n } from '@kbn/i18n'; -export const management = capabilities => { - const main = new ManagementSection( - 'management', - { - display: i18n.translate('management.displayName', { - defaultMessage: 'Management', - }), - }, - capabilities - ); +export class LegacyManagementAdapter { + main = undefined; + init = capabilities => { + this.main = new LegacyManagementSection( + 'management', + { + display: i18n.translate('management.displayName', { + defaultMessage: 'Management', + }), + }, + capabilities + ); - main.register('data', { - display: i18n.translate('management.connectDataDisplayName', { - defaultMessage: 'Connect Data', - }), - order: 0, - }); + this.main.register('data', { + display: i18n.translate('management.connectDataDisplayName', { + defaultMessage: 'Connect Data', + }), + order: 0, + }); - main.register('elasticsearch', { - display: 'Elasticsearch', - order: 20, - icon: 'logoElasticsearch', - }); + this.main.register('elasticsearch', { + display: 'Elasticsearch', + order: 20, + icon: 'logoElasticsearch', + }); - main.register('kibana', { - display: 'Kibana', - order: 30, - icon: 'logoKibana', - }); + this.main.register('kibana', { + display: 'Kibana', + order: 30, + icon: 'logoKibana', + }); - main.register('logstash', { - display: 'Logstash', - order: 30, - icon: 'logoLogstash', - }); + this.main.register('logstash', { + display: 'Logstash', + order: 30, + icon: 'logoLogstash', + }); - return main; -}; + return this.main; + }; + getManagement = () => this.main; +} diff --git a/src/plugins/management/public/management_app.test.tsx b/src/plugins/management/public/management_app.test.tsx new file mode 100644 index 00000000000000..a76b234d95ef51 --- /dev/null +++ b/src/plugins/management/public/management_app.test.tsx @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { coreMock } from '../../../core/public/mocks'; + +import { ManagementApp } from './management_app'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; + +function createTestApp() { + const legacySection = new LegacyManagementSection('legacy'); + return new ManagementApp( + { + id: 'test-app', + title: 'Test App', + basePath: '', + mount(params) { + params.setBreadcrumbs([{ text: 'Test App' }]); + ReactDOM.render(
Test App - Hello world!
, params.element); + + return () => { + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }, + () => [], + jest.fn(), + () => legacySection, + coreMock.createSetup().getStartServices + ); +} + +test('Management app can mount and unmount', async () => { + const testApp = createTestApp(); + const container = document.createElement('div'); + document.body.appendChild(container); + const unmount = testApp.mount({ element: container, basePath: '', setBreadcrumbs: jest.fn() }); + expect(container).toMatchSnapshot(); + (await unmount)(); + expect(container).toMatchSnapshot(); +}); + +test('Enabled by default, can disable', () => { + const testApp = createTestApp(); + expect(testApp.enabled).toBe(true); + testApp.disable(); + expect(testApp.enabled).toBe(false); +}); diff --git a/src/plugins/management/public/management_app.tsx b/src/plugins/management/public/management_app.tsx new file mode 100644 index 00000000000000..f7e8dba4f82100 --- /dev/null +++ b/src/plugins/management/public/management_app.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { CreateManagementApp, ManagementSectionMount, Unmount } from './types'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { ManagementChrome } from './components'; +import { ManagementSection } from './management_section'; +import { ChromeBreadcrumb, CoreSetup } from '../../../core/public/'; + +export class ManagementApp { + readonly id: string; + readonly title: string; + readonly basePath: string; + readonly order: number; + readonly mount: ManagementSectionMount; + protected enabledStatus: boolean = true; + + constructor( + { id, title, basePath, order = 100, mount }: CreateManagementApp, + getSections: () => ManagementSection[], + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagementSections: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + this.id = id; + this.title = title; + this.basePath = basePath; + this.order = order; + this.mount = mount; + + registerLegacyApp({ + id: basePath.substr(1), // get rid of initial slash + title, + mount: async ({}, params) => { + let appUnmount: Unmount; + async function setBreadcrumbs(crumbs: ChromeBreadcrumb[]) { + const [coreStart] = await getStartServices(); + coreStart.chrome.setBreadcrumbs([ + { + text: i18n.translate('management.breadcrumb', { + defaultMessage: 'Management', + }), + href: '#/management', + }, + ...crumbs, + ]); + } + + ReactDOM.render( + { + appUnmount = await mount({ + basePath, + element, + setBreadcrumbs, + }); + }} + />, + params.element + ); + + return async () => { + appUnmount(); + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }); + } + public enable() { + this.enabledStatus = true; + } + public disable() { + this.enabledStatus = false; + } + public get enabled() { + return this.enabledStatus; + } +} diff --git a/src/plugins/management/public/management_section.test.ts b/src/plugins/management/public/management_section.test.ts new file mode 100644 index 00000000000000..c68175ee0a678b --- /dev/null +++ b/src/plugins/management/public/management_section.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ManagementSection } from './management_section'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { coreMock } from '../../../core/public/mocks'; + +function createSection(registerLegacyApp: () => void) { + const legacySection = new LegacyManagementSection('legacy'); + const getLegacySection = () => legacySection; + const getManagementSections: () => ManagementSection[] = () => []; + + const testSectionConfig = { id: 'test-section', title: 'Test Section' }; + return new ManagementSection( + testSectionConfig, + getManagementSections, + registerLegacyApp, + getLegacySection, + coreMock.createSetup().getStartServices + ); +} + +test('cannot register two apps with the same id', () => { + const registerLegacyApp = jest.fn(); + const section = createSection(registerLegacyApp); + + const testAppConfig = { id: 'test-app', title: 'Test App', mount: () => () => {} }; + + section.registerApp(testAppConfig); + expect(registerLegacyApp).toHaveBeenCalled(); + expect(section.apps.length).toEqual(1); + + expect(() => { + section.registerApp(testAppConfig); + }).toThrow(); +}); + +test('can enable and disable apps', () => { + const registerLegacyApp = jest.fn(); + const section = createSection(registerLegacyApp); + + const testAppConfig = { id: 'test-app', title: 'Test App', mount: () => () => {} }; + + const app = section.registerApp(testAppConfig); + expect(section.getAppsEnabled().length).toEqual(1); + app.disable(); + expect(section.getAppsEnabled().length).toEqual(0); +}); diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts new file mode 100644 index 00000000000000..2f323c4b6a9cf3 --- /dev/null +++ b/src/plugins/management/public/management_section.ts @@ -0,0 +1,78 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CreateSection, RegisterManagementAppArgs } from './types'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +import { CoreSetup } from '../../../core/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { ManagementApp } from './management_app'; + +export class ManagementSection { + public readonly id: string = ''; + public readonly title: string = ''; + public readonly apps: ManagementApp[] = []; + public readonly order: number; + public readonly euiIconType?: string; + public readonly icon?: string; + private readonly getSections: () => ManagementSection[]; + private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; + private readonly getLegacyManagementSection: () => LegacyManagementSection; + private readonly getStartServices: CoreSetup['getStartServices']; + + constructor( + { id, title, order = 100, euiIconType, icon }: CreateSection, + getSections: () => ManagementSection[], + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagementSection: () => ManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + this.id = id; + this.title = title; + this.order = order; + this.euiIconType = euiIconType; + this.icon = icon; + this.getSections = getSections; + this.registerLegacyApp = registerLegacyApp; + this.getLegacyManagementSection = getLegacyManagementSection; + this.getStartServices = getStartServices; + } + + registerApp({ id, title, order, mount }: RegisterManagementAppArgs) { + if (this.getApp(id)) { + throw new Error(`Management app already registered - id: ${id}, title: ${title}`); + } + + const app = new ManagementApp( + { id, title, order, mount, basePath: `/management/${this.id}/${id}` }, + this.getSections, + this.registerLegacyApp, + this.getLegacyManagementSection, + this.getStartServices + ); + this.apps.push(app); + return app; + } + getApp(id: ManagementApp['id']) { + return this.apps.find(app => app.id === id); + } + getAppsEnabled() { + return this.apps.filter(app => app.enabled).sort((a, b) => a.order - b.order); + } +} diff --git a/src/plugins/management/public/management_service.test.ts b/src/plugins/management/public/management_service.test.ts new file mode 100644 index 00000000000000..854406a10335be --- /dev/null +++ b/src/plugins/management/public/management_service.test.ts @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ManagementService } from './management_service'; +import { coreMock } from '../../../core/public/mocks'; + +const mockKibanaLegacy = { registerLegacyApp: () => {}, forwardApp: () => {} }; + +test('Provides default sections', () => { + const service = new ManagementService().setup( + mockKibanaLegacy, + () => {}, + coreMock.createSetup().getStartServices + ); + expect(service.getAllSections().length).toEqual(3); + expect(service.getSection('kibana')).not.toBeUndefined(); + expect(service.getSection('logstash')).not.toBeUndefined(); + expect(service.getSection('elasticsearch')).not.toBeUndefined(); +}); + +test('Register section, enable and disable', () => { + const service = new ManagementService().setup( + mockKibanaLegacy, + () => {}, + coreMock.createSetup().getStartServices + ); + const testSection = service.register({ id: 'test-section', title: 'Test Section' }); + expect(service.getSection('test-section')).not.toBeUndefined(); + + const testApp = testSection.registerApp({ + id: 'test-app', + title: 'Test App', + mount: () => () => {}, + }); + expect(testSection.getApp('test-app')).not.toBeUndefined(); + expect(service.getSectionsEnabled().length).toEqual(1); + testApp.disable(); + expect(service.getSectionsEnabled().length).toEqual(0); +}); diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts new file mode 100644 index 00000000000000..4a900345b38436 --- /dev/null +++ b/src/plugins/management/public/management_service.ts @@ -0,0 +1,103 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ManagementSection } from './management_section'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; +// @ts-ignore +import { LegacyManagementSection } from './legacy'; +import { CreateSection } from './types'; +import { CoreSetup, CoreStart } from '../../../core/public'; + +export class ManagementService { + private sections: ManagementSection[] = []; + + private register( + registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], + getLegacyManagement: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + return (section: CreateSection) => { + if (this.getSection(section.id)) { + throw Error(`ManagementSection '${section.id}' already registered`); + } + + const newSection = new ManagementSection( + section, + this.getSectionsEnabled.bind(this), + registerLegacyApp, + getLegacyManagement, + getStartServices + ); + this.sections.push(newSection); + return newSection; + }; + } + private getSection(sectionId: ManagementSection['id']) { + return this.sections.find(section => section.id === sectionId); + } + + private getAllSections() { + return this.sections; + } + + private getSectionsEnabled() { + return this.sections + .filter(section => section.getAppsEnabled().length > 0) + .sort((a, b) => a.order - b.order); + } + + private sharedInterface = { + getSection: this.getSection.bind(this), + getSectionsEnabled: this.getSectionsEnabled.bind(this), + getAllSections: this.getAllSections.bind(this), + }; + + public setup( + kibanaLegacy: KibanaLegacySetup, + getLegacyManagement: () => LegacyManagementSection, + getStartServices: CoreSetup['getStartServices'] + ) { + const register = this.register.bind(this)( + kibanaLegacy.registerLegacyApp, + getLegacyManagement, + getStartServices + ); + + register({ id: 'kibana', title: 'Kibana', order: 30, euiIconType: 'logoKibana' }); + register({ id: 'logstash', title: 'Logstash', order: 30, euiIconType: 'logoLogstash' }); + register({ + id: 'elasticsearch', + title: 'Elasticsearch', + order: 20, + euiIconType: 'logoElasticsearch', + }); + + return { + register, + ...this.sharedInterface, + }; + } + + public start(navigateToApp: CoreStart['application']['navigateToApp']) { + return { + navigateToApp, // apps are currently registered as top level apps but this may change in the future + ...this.sharedInterface, + }; + } +} diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index c65dfd1dc7bb45..195d96c11d8d92 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -18,18 +18,30 @@ */ import { CoreSetup, CoreStart, Plugin } from 'kibana/public'; -import { ManagementStart } from './types'; +import { ManagementSetup, ManagementStart } from './types'; +import { ManagementService } from './management_service'; +import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore -import { management } from './legacy'; +import { LegacyManagementAdapter } from './legacy'; -export class ManagementPlugin implements Plugin<{}, ManagementStart> { - public setup(core: CoreSetup) { - return {}; +export class ManagementPlugin implements Plugin { + private managementSections = new ManagementService(); + private legacyManagement = new LegacyManagementAdapter(); + + public setup(core: CoreSetup, { kibana_legacy }: { kibana_legacy: KibanaLegacySetup }) { + return { + sections: this.managementSections.setup( + kibana_legacy, + this.legacyManagement.getManagement, + core.getStartServices + ), + }; } public start(core: CoreStart) { return { - legacy: management(core.application.capabilities), + sections: this.managementSections.start(core.application.navigateToApp), + legacy: this.legacyManagement.init(core.application.capabilities), }; } } diff --git a/src/plugins/management/public/types.ts b/src/plugins/management/public/types.ts index 6ca1faf338c39f..4dbea30ff062da 100644 --- a/src/plugins/management/public/types.ts +++ b/src/plugins/management/public/types.ts @@ -17,6 +17,82 @@ * under the License. */ +import { IconType } from '@elastic/eui'; +import { ManagementApp } from './management_app'; +import { ManagementSection } from './management_section'; +import { ChromeBreadcrumb, ApplicationStart } from '../../../core/public/'; + +export interface ManagementSetup { + sections: SectionsServiceSetup; +} + export interface ManagementStart { + sections: SectionsServiceStart; legacy: any; } + +interface SectionsServiceSetup { + getSection: (sectionId: ManagementSection['id']) => ManagementSection | undefined; + getAllSections: () => ManagementSection[]; + register: RegisterSection; +} + +interface SectionsServiceStart { + getSection: (sectionId: ManagementSection['id']) => ManagementSection | undefined; + getAllSections: () => ManagementSection[]; + navigateToApp: ApplicationStart['navigateToApp']; +} + +export interface CreateSection { + id: string; + title: string; + order?: number; + euiIconType?: string; // takes precedence over `icon` property. + icon?: string; // URL to image file; fallback if no `euiIconType` +} + +export type RegisterSection = (section: CreateSection) => ManagementSection; + +export interface RegisterManagementAppArgs { + id: string; + title: string; + mount: ManagementSectionMount; + order?: number; +} + +export type RegisterManagementApp = (managementApp: RegisterManagementAppArgs) => ManagementApp; + +export type Unmount = () => Promise | void; + +interface ManagementAppMountParams { + basePath: string; // base path for setting up your router + element: HTMLElement; // element the section should render into + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; +} + +export type ManagementSectionMount = ( + params: ManagementAppMountParams +) => Unmount | Promise; + +export interface CreateManagementApp { + id: string; + title: string; + basePath: string; + order?: number; + mount: ManagementSectionMount; +} + +export interface LegacySection extends LegacyApp { + visibleItems: LegacyApp[]; +} + +export interface LegacyApp { + disabled: boolean; + visible: boolean; + id: string; + display: string; + url?: string; + euiIconType?: IconType; + icon?: string; + order: number; +} diff --git a/tasks/config/karma.js b/tasks/config/karma.js index c0d6074da61c56..0acd452530b305 100644 --- a/tasks/config/karma.js +++ b/tasks/config/karma.js @@ -20,6 +20,7 @@ import { dirname } from 'path'; import { times } from 'lodash'; import { makeJunitReportPath } from '@kbn/test'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; const TOTAL_CI_SHARDS = 4; const ROOT = dirname(require.resolve('../../package.json')); @@ -48,6 +49,25 @@ module.exports = function(grunt) { return ['progress']; } + function getKarmaFiles(shardNum) { + return [ + 'http://localhost:5610/test_bundle/built_css.css', + + `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.distFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', + + shardNum === undefined + ? `http://localhost:5610/bundles/tests.bundle.js` + : `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`, + + // this causes tilemap tests to fail, probably because the eui styles haven't been + // included in the karma harness a long some time, if ever + // `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', + 'http://localhost:5610/bundles/tests.style.css', + ]; + } + const config = { options: { // base path that will be used to resolve all patterns (eg. files, exclude) @@ -90,15 +110,7 @@ module.exports = function(grunt) { }, // list of files / patterns to load in the browser - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - 'http://localhost:5610/bundles/tests.bundle.js', - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(), proxies: { '/tests/': 'http://localhost:5610/tests/', @@ -181,15 +193,7 @@ module.exports = function(grunt) { config[`ciShard-${n}`] = { singleRun: true, options: { - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${n}`, - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(n), }, }; }); diff --git a/test/common/services/security/role_mappings.ts b/test/common/services/security/role_mappings.ts new file mode 100644 index 00000000000000..cc2fa238254987 --- /dev/null +++ b/test/common/services/security/role_mappings.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import axios, { AxiosInstance } from 'axios'; +import util from 'util'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class RoleMappings { + private log: ToolingLog; + private axios: AxiosInstance; + + constructor(url: string, log: ToolingLog) { + this.log = log; + this.axios = axios.create({ + headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role_mappings' }, + baseURL: url, + maxRedirects: 0, + validateStatus: () => true, // we do our own validation below and throw better error messages + }); + } + + public async create(name: string, roleMapping: Record) { + this.log.debug(`creating role mapping ${name}`); + const { data, status, statusText } = await this.axios.post( + `/internal/security/role_mapping/${name}`, + roleMapping + ); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`created role mapping ${name}`); + } + + public async delete(name: string) { + this.log.debug(`deleting role mapping ${name}`); + const { data, status, statusText } = await this.axios.delete( + `/internal/security/role_mapping/${name}` + ); + if (status !== 200 && status !== 404) { + throw new Error( + `Expected status code of 200 or 404, received ${status} ${statusText}: ${util.inspect( + data + )}` + ); + } + this.log.debug(`deleted role mapping ${name}`); + } +} diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts index 6649a765a9e508..4eebb7b6697e01 100644 --- a/test/common/services/security/security.ts +++ b/test/common/services/security/security.ts @@ -21,6 +21,7 @@ import { format as formatUrl } from 'url'; import { Role } from './role'; import { User } from './user'; +import { RoleMappings } from './role_mappings'; import { FtrProviderContext } from '../../ftr_provider_context'; export function SecurityServiceProvider({ getService }: FtrProviderContext) { @@ -30,6 +31,7 @@ export function SecurityServiceProvider({ getService }: FtrProviderContext) { return new (class SecurityService { role = new Role(url, log); + roleMappings = new RoleMappings(url, log); user = new User(url, log); })(); } diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js new file mode 100644 index 00000000000000..4780f36fc27c6e --- /dev/null +++ b/test/functional/apps/discover/_discover_histogram.js @@ -0,0 +1,91 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; + +export default function({ getService, getPageObjects }) { + const log = getService('log'); + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['settings', 'common', 'discover', 'header', 'timePicker']); + const defaultSettings = { + defaultIndex: 'long-window-logstash-*', + 'dateFormat:tz': 'Europe/Berlin', + }; + + describe('discover histogram', function describeIndexTests() { + before(async function() { + log.debug('load kibana index with default index pattern'); + await PageObjects.common.navigateToApp('home'); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.load('long_window_logstash'); + await esArchiver.load('visualize'); + await esArchiver.load('discover'); + + log.debug('create long_window_logstash index pattern'); + // NOTE: long_window_logstash load does NOT create index pattern + await PageObjects.settings.createIndexPattern('long-window-logstash-'); + await kibanaServer.uiSettings.replace(defaultSettings); + await browser.refresh(); + + log.debug('discover'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.selectIndexPattern('long-window-logstash-*'); + // NOTE: For some reason without setting this relative time, the abs times will not fetch data. + await PageObjects.timePicker.setCommonlyUsedTime('superDatePickerCommonlyUsed_Last_1 year'); + }); + after(async () => { + await esArchiver.unload('long_window_logstash'); + await esArchiver.unload('visualize'); + await esArchiver.unload('discover'); + }); + + it('should visualize monthly data with different day intervals', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2017-11-01 00:00:00.000'; + const toTime = '2018-03-21 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Monthly'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize weekly data with within DST changes', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2018-03-01 00:00:00.000'; + const toTime = '2018-05-01 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Weekly'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize monthly data with different years Scaled to 30d', async () => { + //Nov 1, 2017 @ 01:00:00.000 - Mar 21, 2018 @ 02:00:00.000 + const fromTime = '2010-01-01 00:00:00.000'; + const toTime = '2018-03-21 00:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.setChartInterval('Daily'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const chartCanvasExist = await PageObjects.discover.chartCanvasExist(); + expect(chartCanvasExist).to.be(true); + }); + }); +} diff --git a/test/functional/apps/discover/index.js b/test/functional/apps/discover/index.js index e10e772e93ab1c..64a5a61335365e 100644 --- a/test/functional/apps/discover/index.js +++ b/test/functional/apps/discover/index.js @@ -34,6 +34,7 @@ export default function({ getService, loadTestFile }) { loadTestFile(require.resolve('./_saved_queries')); loadTestFile(require.resolve('./_discover')); + loadTestFile(require.resolve('./_discover_histogram')); loadTestFile(require.resolve('./_filter_editor')); loadTestFile(require.resolve('./_errors')); loadTestFile(require.resolve('./_field_data')); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 06406bddeb009d..3d9368f8d46807 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -74,7 +74,7 @@ export default function({ getService, getPageObjects }) { const toTime = 'Nov 19, 2016 @ 05:00:00.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('alias2'); + await PageObjects.discover.selectIndexPattern('alias2*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await retry.try(async function() { diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 7029fbf9e1350b..3ba0f217813f2c 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -117,8 +117,16 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { await testSubjects.click('discoverOpenButton'); } + async getChartCanvas() { + return await find.byCssSelector('.echChart canvas:last-of-type'); + } + + async chartCanvasExist() { + return await find.existsByCssSelector('.echChart canvas:last-of-type'); + } + async clickHistogramBar() { - const el = await find.byCssSelector('.echChart canvas:last-of-type'); + const el = await this.getChartCanvas(); await browser .getActions() @@ -128,7 +136,8 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { } async brushHistogram() { - const el = await find.byCssSelector('.echChart canvas:last-of-type'); + const el = await this.getChartCanvas(); + await browser.dragAndDrop( { location: el, offset: { x: 200, y: 20 } }, { location: el, offset: { x: 400, y: 30 } } @@ -279,7 +288,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { async selectIndexPattern(indexPattern) { await testSubjects.click('indexPattern-switch-link'); await find.clickByCssSelector( - `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}*"]` + `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` ); await PageObjects.header.waitUntilLoadingHasFinished(); } diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts index a4cd98b2a06ec1..fe17532f6a41ac 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -25,7 +25,7 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return new (class AppsMenu { /** - * Get the text and href from each of the links in the apps menu + * Get the attributes from each of the links in the apps menu */ public async readLinks() { const appMenu = await testSubjects.find('navDrawer'); @@ -37,12 +37,21 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return { text: $(link).text(), href: $(link).attr('href'), + disabled: $(link).attr('disabled') != null, }; }); return links; } + /** + * Get the attributes from the link with the given name. + * @param name + */ + public async getLink(name: string) { + return (await this.readLinks()).find(nl => nl.text === name); + } + /** * Determine if an app link with the given name exists * @param name diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index 87026ce25d9aa3..e9a4f3bcc4b1a9 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -37,6 +37,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/panel_actions'), require.resolve('./test_suites/embeddable_explorer'), require.resolve('./test_suites/core_plugins'), + require.resolve('./test_suites/management'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json new file mode 100644 index 00000000000000..91d8e6fd8f9e18 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_app_status", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_app_status"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_app_status/package.json b/test/plugin_functional/plugins/core_app_status/package.json new file mode 100644 index 00000000000000..61655487c6acbc --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_app_status", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_app_status", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_app_status/public/application.tsx b/test/plugin_functional/plugins/core_app_status/public/application.tsx new file mode 100644 index 00000000000000..323774392a6d73 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/application.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const AppStatusApp = () => ( + + + + + +

Welcome to App Status Test App!

+
+
+
+ + + + +

App Status Test App home page section title

+
+
+
+ App Status Test App content +
+
+
+); + +export const renderApp = (context: AppMountContext, { element }: AppMountParameters) => { + render(, element); + + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_app_status/public/index.ts b/test/plugin_functional/plugins/core_app_status/public/index.ts new file mode 100644 index 00000000000000..e0ad7c25a54b81 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreAppStatusPlugin, CoreAppStatusPluginSetup, CoreAppStatusPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppStatusPlugin(); diff --git a/test/plugin_functional/plugins/core_app_status/public/plugin.tsx b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx new file mode 100644 index 00000000000000..85caaaf5f9090f --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup, AppUpdater, AppUpdatableFields, CoreStart } from 'kibana/public'; +import { BehaviorSubject } from 'rxjs'; + +export class CoreAppStatusPlugin + implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'app_status', + title: 'App Status', + euiIconType: 'snowflake', + updater$: this.appUpdater, + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return { + setAppStatus: (status: Partial) => { + this.appUpdater.next(() => status); + }, + navigateToApp: async (appId: string) => { + return core.application.navigateToApp(appId); + }, + }; + } + public stop() {} +} + +export type CoreAppStatusPluginSetup = ReturnType; +export type CoreAppStatusPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json new file mode 100644 index 00000000000000..5fcaeafbb0d852 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/plugins/core_plugin_appleave/kibana.json b/test/plugin_functional/plugins/core_plugin_appleave/kibana.json new file mode 100644 index 00000000000000..95343cbcf28045 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_appleave/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_plugin_appleave", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_plugin_appleave"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_plugin_appleave/package.json b/test/plugin_functional/plugins/core_plugin_appleave/package.json new file mode 100644 index 00000000000000..e0488655a17235 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_appleave/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_plugin_appleave", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_plugin_appleave", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_plugin_appleave/public/application.tsx b/test/plugin_functional/plugins/core_plugin_appleave/public/application.tsx new file mode 100644 index 00000000000000..d0b024f90c737b --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_appleave/public/application.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountParameters } from 'kibana/public'; + +const App = ({ appName }: { appName: string }) => ( + + + + + +

Welcome to {appName}!

+
+
+
+ + + + +

{appName} home page section title

+
+
+
+ {appName} page content +
+
+
+); + +export const renderApp = (appName: string, { element }: AppMountParameters) => { + render(, element); + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_plugin_appleave/public/index.ts b/test/plugin_functional/plugins/core_plugin_appleave/public/index.ts new file mode 100644 index 00000000000000..3eb2279aa91667 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_appleave/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreAppLeavePlugin, CoreAppLeavePluginSetup, CoreAppLeavePluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppLeavePlugin(); diff --git a/test/plugin_functional/plugins/core_plugin_appleave/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_appleave/public/plugin.tsx new file mode 100644 index 00000000000000..336bb9d787895b --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_appleave/public/plugin.tsx @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup } from 'kibana/public'; + +export class CoreAppLeavePlugin + implements Plugin { + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'appleave1', + title: 'AppLeave 1', + async mount(context, params) { + const { renderApp } = await import('./application'); + params.onAppLeave(actions => actions.confirm('confirm-message', 'confirm-title')); + return renderApp('AppLeave 1', params); + }, + }); + core.application.register({ + id: 'appleave2', + title: 'AppLeave 2', + async mount(context, params) { + const { renderApp } = await import('./application'); + params.onAppLeave(actions => actions.default()); + return renderApp('AppLeave 2', params); + }, + }); + + return {}; + } + + public start() {} + public stop() {} +} + +export type CoreAppLeavePluginSetup = ReturnType; +export type CoreAppLeavePluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json b/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json new file mode 100644 index 00000000000000..5fcaeafbb0d852 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_appleave/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/plugins/management_test_plugin/kibana.json b/test/plugin_functional/plugins/management_test_plugin/kibana.json new file mode 100644 index 00000000000000..e52b60b3a4e313 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "management_test_plugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["management_test_plugin"], + "server": false, + "ui": true, + "requiredPlugins": ["management"] +} diff --git a/test/plugin_functional/plugins/management_test_plugin/package.json b/test/plugin_functional/plugins/management_test_plugin/package.json new file mode 100644 index 00000000000000..656d92e9eb1f78 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "management_test_plugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/management_test_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/test/plugin_functional/plugins/management_test_plugin/public/index.ts b/test/plugin_functional/plugins/management_test_plugin/public/index.ts new file mode 100644 index 00000000000000..1efcc6cd3bbd62 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/public/index.ts @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { + ManagementTestPlugin, + ManagementTestPluginSetup, + ManagementTestPluginStart, +} from './plugin'; + +export const plugin: PluginInitializer = () => + new ManagementTestPlugin(); diff --git a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx new file mode 100644 index 00000000000000..8b7cdd653ed8c3 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx @@ -0,0 +1,73 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router, Switch, Route, Link } from 'react-router-dom'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { ManagementSetup } from '../../../../../src/plugins/management/public'; + +export class ManagementTestPlugin + implements Plugin { + public setup(core: CoreSetup, { management }: { management: ManagementSetup }) { + const testSection = management.sections.register({ + id: 'test-section', + title: 'Test Section', + euiIconType: 'logoKibana', + order: 25, + }); + + testSection!.registerApp({ + id: 'test-management', + title: 'Management Test', + mount(params) { + params.setBreadcrumbs([{ text: 'Management Test' }]); + ReactDOM.render( + +

Hello from management test plugin

+ + + + Link to /one + + + + + Link to basePath + + + +
, + params.element + ); + + return () => { + ReactDOM.unmountComponentAtNode(params.element); + }; + }, + }); + return {}; + } + + public start() {} + public stop() {} +} + +export type ManagementTestPluginSetup = ReturnType; +export type ManagementTestPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/management_test_plugin/tsconfig.json b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json new file mode 100644 index 00000000000000..5fcaeafbb0d852 --- /dev/null +++ b/test/plugin_functional/plugins/management_test_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core_plugins/application_leave_confirm.ts b/test/plugin_functional/test_suites/core_plugins/application_leave_confirm.ts new file mode 100644 index 00000000000000..d164c2e0bc369d --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/application_leave_confirm.ts @@ -0,0 +1,80 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import url from 'url'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +const getKibanaUrl = (pathname?: string, search?: string) => + url.format({ + protocol: 'http:', + hostname: process.env.TEST_KIBANA_HOST || 'localhost', + port: process.env.TEST_KIBANA_PORT || '5620', + pathname, + search, + }); + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + const testSubjects = getService('testSubjects'); + + describe('application using leave confirmation', () => { + describe('when navigating to another app', () => { + it('prevents navigation if user click cancel on the confirmation dialog', async () => { + await PageObjects.common.navigateToApp('appleave1'); + await appsMenu.clickLink('AppLeave 2'); + + await testSubjects.existOrFail('appLeaveConfirmModal'); + await PageObjects.common.clickCancelOnModal(false); + expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/appleave1')); + }); + it('allows navigation if user click confirm on the confirmation dialog', async () => { + await PageObjects.common.navigateToApp('appleave1'); + await appsMenu.clickLink('AppLeave 2'); + + await testSubjects.existOrFail('appLeaveConfirmModal'); + await PageObjects.common.clickConfirmOnModal(); + expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/appleave2')); + }); + }); + + describe('when navigating to a legacy app', () => { + it('prevents navigation if user click cancel on the alert dialog', async () => { + await PageObjects.common.navigateToApp('appleave1'); + await appsMenu.clickLink('Core Legacy Compat'); + + const alert = await browser.getAlert(); + expect(alert).not.to.eql(undefined); + alert!.dismiss(); + expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/appleave1')); + }); + it('allows navigation if user click leave on the alert dialog', async () => { + await PageObjects.common.navigateToApp('appleave1'); + await appsMenu.clickLink('Core Legacy Compat'); + + const alert = await browser.getAlert(); + expect(alert).not.to.eql(undefined); + alert!.accept(); + expect(await browser.getCurrentUrl()).to.eql(getKibanaUrl('/app/core_plugin_legacy')); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts new file mode 100644 index 00000000000000..703ae30533bae0 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, +} from '../../../../src/core/public/application/types'; +import { PluginFunctionalProviderContext } from '../../services'; +import { CoreAppStatusPluginStart } from '../../plugins/core_app_status/public/plugin'; +import '../../plugins/core_provider_plugin/types'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + + const setAppStatus = async (s: Partial) => { + await browser.executeAsync(async (status: Partial, cb: Function) => { + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + plugin.setAppStatus(status); + cb(); + }, s); + }; + + const navigateToApp = async (i: string): Promise<{ error?: string }> => { + return (await browser.executeAsync(async (appId, cb: Function) => { + // navigating in legacy mode performs a page refresh + // and webdriver seems to re-execute the script after the reload + // as it considers it didn't end on the previous session. + // however when testing navigation to NP app, __coreProvider is not accessible + // so we need to check for existence. + if (!window.__coreProvider) { + cb({}); + } + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + try { + await plugin.navigateToApp(appId); + cb({}); + } catch (e) { + cb({ + error: e.message, + }); + } + }, i)) as any; + }; + + describe('application status management', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('settings'); + }); + + it('can change the navLink status at runtime', async () => { + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.disabled, + }); + let link = await appsMenu.getLink('App Status'); + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(true); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.hidden, + }); + link = await appsMenu.getLink('App Status'); + expect(link).to.eql(undefined); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.visible, + tooltip: 'Some tooltip', + }); + link = await appsMenu.getLink('Some tooltip'); // the tooltip replaces the name in the selector we use. + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(false); + }); + + it('shows an error when navigating to an inaccessible app', async () => { + await setAppStatus({ + status: AppStatus.inaccessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.contain( + 'Trying to navigate to an inaccessible application: app_status' + ); + }); + + it('allows to navigate to an accessible app', async () => { + await setAppStatus({ + status: AppStatus.accessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.eql(undefined); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index bf33f37694c3a7..d66e2e7dc5da73 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -27,5 +27,7 @@ export default function({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./ui_plugins')); loadTestFile(require.resolve('./ui_settings')); loadTestFile(require.resolve('./top_nav')); + loadTestFile(require.resolve('./application_leave_confirm')); + loadTestFile(require.resolve('./application_status')); }); } diff --git a/test/plugin_functional/test_suites/management/index.js b/test/plugin_functional/test_suites/management/index.js new file mode 100644 index 00000000000000..2bfc05547b2922 --- /dev/null +++ b/test/plugin_functional/test_suites/management/index.js @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export default function({ loadTestFile }) { + describe('management plugin', () => { + loadTestFile(require.resolve('./management_plugin')); + }); +} diff --git a/test/plugin_functional/test_suites/management/management_plugin.js b/test/plugin_functional/test_suites/management/management_plugin.js new file mode 100644 index 00000000000000..d65fb1dcd3a7e3 --- /dev/null +++ b/test/plugin_functional/test_suites/management/management_plugin.js @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export default function({ getService, getPageObjects }) { + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + + describe('management plugin', function describeIndexTests() { + before(async () => { + await PageObjects.common.navigateToActualUrl('kibana', 'management'); + }); + + it('should be able to navigate to management test app', async () => { + await testSubjects.click('test-management'); + await testSubjects.existOrFail('test-management-header'); + }); + + it('should be able to navigate within management test app', async () => { + await testSubjects.click('test-management-link-one'); + await testSubjects.click('test-management-link-basepath'); + await testSubjects.existOrFail('test-management-link-one'); + }); + }); +} diff --git a/test/server_integration/__fixtures__/README.md b/test/server_integration/__fixtures__/README.md new file mode 100644 index 00000000000000..faf881202e55e1 --- /dev/null +++ b/test/server_integration/__fixtures__/README.md @@ -0,0 +1,79 @@ +# HTTP SSL Test Fixtures + +These PKCS12 files are used to test SSL with a root CA and an intermediate CA. + +The files that are provided by `@kbn/dev-utils` only use a root CA, so we need additional test files for this. + +To generate these additional test files, see the steps below. + +## Step 1. Set environment variables + +```sh +CA1='test_root_ca' +CA2='test_intermediate_ca' +EE='localhost' +``` + +## Step 2. Generate PKCS12 key stores + +Using [elasticsearch-certutil](https://www.elastic.co/guide/en/elasticsearch/reference/current/certutil.html): + +```sh +bin/elasticsearch-certutil ca --ca-dn "CN=Test Root CA" -days 18250 --out $CA1.p12 --pass castorepass +bin/elasticsearch-certutil ca --ca-dn "CN=Test Intermediate CA" -days 18250 --out $CA2.p12 --pass castorepass +bin/elasticsearch-certutil cert --ca $CA2.p12 --ca-pass castorepass --name $EE --dns $EE --out $EE.p12 --pass storepass +``` + +## Step 3. Convert PKCS12 key stores + +Using OpenSSL on macOS: + +```sh +### CONVERT P12 KEYSTORES TO PEM FILES +openssl pkcs12 -in $CA1.p12 -out $CA1.crt -nokeys -passin pass:"castorepass" -passout pass: +openssl pkcs12 -in $CA1.p12 -nocerts -passin pass:"castorepass" -passout pass:"keypass" | openssl rsa -passin pass:"keypass" -out $CA1.key + +openssl pkcs12 -in $CA2.p12 -out $CA2.crt -nokeys -passin pass:"castorepass" -passout pass: +openssl pkcs12 -in $CA2.p12 -nocerts -passin pass:"castorepass" -passout pass:"keypass" | openssl rsa -passin pass:"keypass" -out $CA2.key + +openssl pkcs12 -in $EE.p12 -out $EE.crt -clcerts -passin pass:"storepass" -passout pass: +openssl pkcs12 -in $EE.p12 -nocerts -passin pass:"storepass" -passout pass:"keypass" | openssl rsa -passin pass:"keypass" -out $EE.key + +### RE-SIGN INTERMEDIATE CA CERT +mkdir -p ./tmp +openssl x509 -x509toreq -in $CA2.crt -signkey $CA2.key -out ./tmp/$CA2.csr +dd if=/dev/urandom of=./tmp/rand bs=256 count=1 +touch ./tmp/index.txt +echo "01" > ./tmp/serial +cp /System/Library/OpenSSL/openssl.cnf ./tmp/ +echo " +[ tmpcnf ] +dir = ./ +certs = ./ +new_certs_dir = ./tmp +crl_dir = ./tmp/crl +database = ./tmp/index.txt +unique_subject = no +certificate = ./$CA1.crt +serial = ./tmp/serial +crlnumber = ./tmp/crlnumber +crl = ./tmp/crl.pem +private_key = ./$CA1.key +RANDFILE = ./tmp/rand +x509_extensions = v3_ca +name_opt = ca_default +cert_opt = ca_default +default_days = 18250 +default_crl_days= 30 +default_md = sha256 +preserve = no +policy = policy_anything +" >> ./tmp/openssl.cnf + +# The next command requires user input +openssl ca -config ./tmp/openssl.cnf -name tmpcnf -in ./tmp/$CA2.csr -out $CA2.crt -verbose + +### CONVERT PEM FILES BACK TO P12 KEYSTORES +cat $CA2.key $CA2.crt $CA1.crt | openssl pkcs12 -export -name $CA2 -passout pass:"castorepass" -out $CA2.p12 +cat $EE.key $EE.crt $CA1.crt $CA2.crt | openssl pkcs12 -export -name $EE -passout pass:"storepass" -out $EE.p12 +``` diff --git a/test/server_integration/__fixtures__/index.ts b/test/server_integration/__fixtures__/index.ts new file mode 100644 index 00000000000000..40f1ddb7fa0bac --- /dev/null +++ b/test/server_integration/__fixtures__/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; + +export const CA1_CERT_PATH = resolve(__dirname, './test_root_ca.crt'); +export const CA2_CERT_PATH = resolve(__dirname, './test_intermediate_ca.crt'); +export const EE_P12_PATH = resolve(__dirname, './localhost.p12'); +export const EE_P12_PASSWORD = 'storepass'; diff --git a/test/server_integration/__fixtures__/localhost.p12 b/test/server_integration/__fixtures__/localhost.p12 new file mode 100644 index 00000000000000..1b0d11fb880980 Binary files /dev/null and b/test/server_integration/__fixtures__/localhost.p12 differ diff --git a/test/server_integration/__fixtures__/test_intermediate_ca.crt b/test/server_integration/__fixtures__/test_intermediate_ca.crt new file mode 100644 index 00000000000000..2e143200d290a7 --- /dev/null +++ b/test/server_integration/__fixtures__/test_intermediate_ca.crt @@ -0,0 +1,79 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 1 (0x1) + Signature Algorithm: sha256WithRSAEncryption + Issuer: CN=Test Root CA + Validity + Not Before: Jan 9 15:50:00 2020 GMT + Not After : Dec 27 15:50:00 2069 GMT + Subject: CN=Test Intermediate CA + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:ba:bb:d5:d7:5a:0a:b0:95:43:63:73:bc:1f:3f: + a4:71:3c:3f:69:96:8c:5d:e1:5b:82:95:2c:d1:b3: + 3b:7a:5c:f0:54:c9:d2:be:37:c7:81:ce:db:90:fa: + c0:0c:e8:b7:e5:51:18:29:0b:47:89:0f:b1:e3:7f: + 06:f0:fe:f6:a7:e8:42:36:58:4b:c7:04:81:48:5b: + 20:11:be:95:6b:bd:8c:0b:1b:21:2d:26:47:0b:c5: + 98:59:d7:a2:35:09:4f:1a:eb:74:d4:bc:fd:df:41: + 45:5d:fd:a6:0e:dd:02:7e:52:a4:21:9d:ac:c7:0e: + 73:50:2d:7b:6e:30:05:20:a2:ee:60:fa:0e:80:7d: + d0:0c:fd:24:ae:ef:96:70:7e:a3:bc:87:e4:fc:50: + 43:a7:a6:ef:dc:0d:7d:9e:02:73:3d:6b:b1:b3:e9: + d5:98:42:2b:ed:63:c1:a2:bb:49:19:a4:5b:d6:6e: + 33:54:44:19:f3:51:db:a4:ea:92:67:13:5e:80:bf: + 6d:1f:59:e4:f0:8c:93:10:38:54:37:8f:a6:4a:42: + 56:5f:db:d6:d5:2c:12:58:4c:42:aa:2c:19:8c:f7: + 30:51:b2:2c:29:c1:6b:29:73:bc:c6:45:63:41:90: + 80:0a:84:d5:02:0c:9c:67:cf:73:4e:62:40:51:ee: + 67:03 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Key Identifier: + B4:48:6A:C8:21:77:A5:03:CF:48:C4:62:74:26:03:3F:BC:88:8C:92 + X509v3 Authority Key Identifier: + keyid:03:9B:FF:88:CA:33:A2:71:C5:31:51:A6:DA:15:EF:44:C2:CB:D3:9F + DirName:/CN=Test Root CA + serial:78:77:49:60:3B:E7:73:18:06:75:45:A7:8E:8E:B4:4E:E4:9A:E3:B0 + + X509v3 Basic Constraints: + CA:TRUE + Signature Algorithm: sha256WithRSAEncryption + a3:10:97:ab:dd:43:8a:5b:c7:a6:b9:33:92:7b:61:fb:f0:3f: + 54:05:50:46:9e:62:11:d3:60:59:77:93:53:48:0b:c9:cf:bc: + c0:3c:b4:47:f4:f6:66:2c:86:76:38:3b:5d:13:77:41:ce:d7: + 16:ca:5e:29:33:1f:a7:ea:82:e4:0c:ad:f8:50:1d:54:cd:28: + a9:22:59:a8:e1:3f:05:b8:fb:5e:54:72:58:fa:a1:3e:f8:99: + bf:d6:50:99:8b:12:52:37:41:be:5f:c9:7d:04:46:8b:fd:8f: + 7f:64:a1:0d:b8:2b:ca:e9:4a:54:e2:bb:8b:39:b7:87:6f:8b: + 17:46:b4:5d:16:aa:75:5c:fb:33:29:52:51:24:7b:f2:d9:b3: + 9b:99:bf:08:6c:2c:43:8a:74:63:c1:32:ed:6b:4a:53:88:51: + c2:10:dd:92:f2:6f:af:65:f1:08:5a:cc:a6:2b:54:95:2b:2a: + a1:90:f2:eb:08:91:26:18:44:b7:49:11:09:c1:1c:aa:2d:b2: + d6:56:02:34:7a:97:fb:60:c5:1e:66:84:c0:40:6f:26:52:77: + 85:a3:ab:d5:8e:f0:d0:d0:2e:e0:6f:8a:de:72:e0:ee:96:e5: + 5d:4a:e9:c1:4c:c6:45:c7:36:6b:7a:1a:a6:64:71:9b:7c:7e: + 59:93:bd:b6 +-----BEGIN CERTIFICATE----- +MIIDODCCAiCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDEwxUZXN0 +IFJvb3QgQ0EwIBcNMjAwMTA5MTU1MDAwWhgPMjA2OTEyMjcxNTUwMDBaMB8xHTAb +BgNVBAMTFFRlc3QgSW50ZXJtZWRpYXRlIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAurvV11oKsJVDY3O8Hz+kcTw/aZaMXeFbgpUs0bM7elzwVMnS +vjfHgc7bkPrADOi35VEYKQtHiQ+x438G8P72p+hCNlhLxwSBSFsgEb6Va72MCxsh +LSZHC8WYWdeiNQlPGut01Lz930FFXf2mDt0CflKkIZ2sxw5zUC17bjAFIKLuYPoO +gH3QDP0kru+WcH6jvIfk/FBDp6bv3A19ngJzPWuxs+nVmEIr7WPBortJGaRb1m4z +VEQZ81HbpOqSZxNegL9tH1nk8IyTEDhUN4+mSkJWX9vW1SwSWExCqiwZjPcwUbIs +KcFrKXO8xkVjQZCACoTVAgycZ89zTmJAUe5nAwIDAQABo4GEMIGBMB0GA1UdDgQW +BBS0SGrIIXelA89IxGJ0JgM/vIiMkjBSBgNVHSMESzBJgBQDm/+IyjOiccUxUaba +Fe9EwsvTn6EbpBkwFzEVMBMGA1UEAxMMVGVzdCBSb290IENBghR4d0lgO+dzGAZ1 +RaeOjrRO5JrjsDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCjEJer +3UOKW8emuTOSe2H78D9UBVBGnmIR02BZd5NTSAvJz7zAPLRH9PZmLIZ2ODtdE3dB +ztcWyl4pMx+n6oLkDK34UB1UzSipIlmo4T8FuPteVHJY+qE++Jm/1lCZixJSN0G+ +X8l9BEaL/Y9/ZKENuCvK6UpU4ruLObeHb4sXRrRdFqp1XPszKVJRJHvy2bObmb8I +bCxDinRjwTLta0pTiFHCEN2S8m+vZfEIWsymK1SVKyqhkPLrCJEmGES3SREJwRyq +LbLWVgI0epf7YMUeZoTAQG8mUneFo6vVjvDQ0C7gb4recuDuluVdSunBTMZFxzZr +ehqmZHGbfH5Zk722 +-----END CERTIFICATE----- diff --git a/test/server_integration/__fixtures__/test_root_ca.crt b/test/server_integration/__fixtures__/test_root_ca.crt new file mode 100644 index 00000000000000..678c9a7467a22d --- /dev/null +++ b/test/server_integration/__fixtures__/test_root_ca.crt @@ -0,0 +1,24 @@ +Bag Attributes + friendlyName: ca + localKeyID: 54 69 6D 65 20 31 35 37 38 35 38 34 39 34 35 33 30 37 +subject=/CN=Test Root CA +issuer=/CN=Test Root CA +-----BEGIN CERTIFICATE----- +MIIDETCCAfmgAwIBAgIUeHdJYDvncxgGdUWnjo60TuSa47AwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAxMMVGVzdCBSb290IENBMCAXDTIwMDEwOTE1NDkwNVoYDzIw +NjkxMjI3MTU0OTA1WjAXMRUwEwYDVQQDEwxUZXN0IFJvb3QgQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2G9Bmax5yFvdWEMleXcFK7G0ir04/sd4v +pRuqYhg+LhxlDOnd7HFtSsI2GGZaBktpL4eWOA8sAZ+eL89P3JV5WFDAuvlK8RZt +ECnPzl7Yar3nhPjNO5F1xbyHCPNSiQVYx7avkLJu3sv/okA65ON+BHYijbNNwS0/ +YtZYZWF7qR6rygXiLHcCIwWwZntBAKHGsBzxZv+28xRMUGsYWHq1PI25CRfDuVub +jC3LpAiJUTkrN5cE8Mpy6R9EH3c/qCk1I2daUKJVJhIzrUrsyNYwpCpbtrE605lK +qsRVkxoAK5i3zZRqiQ/m4FEmr0rTmbLJw09u+jIzfye2ivNiC7PZAgMBAAGjUzBR +MB0GA1UdDgQWBBQDm/+IyjOiccUxUabaFe9EwsvTnzAfBgNVHSMEGDAWgBQDm/+I +yjOiccUxUabaFe9EwsvTnzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA +A4IBAQBclwdYhVNp3I4gTzxl0kbjya28auokp1+NUhAJ++eGGeTySEzbggHWkSSw +jXhzbwri1T+80smvj4XkbSvLzPurSxT1if4kmUDh+XApx/pQb/2l88lRdLqBCcSn +UxrdeDGPGMAYsxH/+/s2rMoaagBb4n8dayBesqIa+Nt+Mf8cqfM30pRGqk5HtoI/ +ZUZDOQ7JJc3mg1usjA3mtgsUQ88zAH9C3fMpf/I3sr2UQqaXYZlzmh3r5U9yNYyw +SV0NnaDd3BVJ4qOumTjlYalRJFDrvn+aNkyPN6XiwxA1qd/uVW5mIJhkoxPYWkG4 +M1b0sea/9IVucYYyXI+GyFNI7B5N +-----END CERTIFICATE----- diff --git a/test/server_integration/config.js b/test/server_integration/config.js index 6928dedb9fb6f1..26e00e5fce294a 100644 --- a/test/server_integration/config.js +++ b/test/server_integration/config.js @@ -18,7 +18,7 @@ */ import { - KibanaSupertestProvider, + createKibanaSupertestProvider, KibanaSupertestWithoutAuthProvider, ElasticsearchSupertestProvider, } from './services'; @@ -30,7 +30,7 @@ export default async function({ readConfigFile }) { return { services: { ...commonConfig.get('services'), - supertest: KibanaSupertestProvider, + supertest: createKibanaSupertestProvider(), supertestWithoutAuth: KibanaSupertestWithoutAuthProvider, esSupertest: ElasticsearchSupertestProvider, }, diff --git a/test/server_integration/http/ssl/config.js b/test/server_integration/http/ssl/config.js index 1cf4cdf6064c18..2f2e7b778d3617 100644 --- a/test/server_integration/http/ssl/config.js +++ b/test/server_integration/http/ssl/config.js @@ -17,12 +17,21 @@ * under the License. */ +import { readFileSync } from 'fs'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +import { createKibanaSupertestProvider } from '../../services'; + export default async function({ readConfigFile }) { const httpConfig = await readConfigFile(require.resolve('../../config')); return { testFiles: [require.resolve('./')], - services: httpConfig.get('services'), + services: { + ...httpConfig.get('services'), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA_CERT_PATH)], + }), + }, servers: { ...httpConfig.get('servers'), kibana: { @@ -39,8 +48,8 @@ export default async function({ readConfigFile }) { serverArgs: [ ...httpConfig.get('kbnTestServer.serverArgs'), '--server.ssl.enabled=true', - `--server.ssl.key=${require.resolve('../../../dev_certs/server.key')}`, - `--server.ssl.certificate=${require.resolve('../../../dev_certs/server.crt')}`, + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, ], }, }; diff --git a/test/server_integration/http/ssl_redirect/config.js b/test/server_integration/http/ssl_redirect/config.js index 36e68eaf8e345a..20ab4a210cc7b0 100644 --- a/test/server_integration/http/ssl_redirect/config.js +++ b/test/server_integration/http/ssl_redirect/config.js @@ -17,7 +17,10 @@ * under the License. */ -import { KibanaSupertestProvider } from '../../services'; +import { readFileSync } from 'fs'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; + +import { createKibanaSupertestProvider } from '../../services'; export default async function({ readConfigFile }) { const httpConfig = await readConfigFile(require.resolve('../../config')); @@ -34,8 +37,10 @@ export default async function({ readConfigFile }) { testFiles: [require.resolve('./')], services: { ...httpConfig.get('services'), - //eslint-disable-next-line new-cap - supertest: arg => KibanaSupertestProvider(arg, supertestOptions), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA_CERT_PATH)], + options: supertestOptions, + }), }, servers: { ...httpConfig.get('servers'), @@ -54,8 +59,8 @@ export default async function({ readConfigFile }) { serverArgs: [ ...httpConfig.get('kbnTestServer.serverArgs'), '--server.ssl.enabled=true', - `--server.ssl.key=${require.resolve('../../../dev_certs/server.key')}`, - `--server.ssl.certificate=${require.resolve('../../../dev_certs/server.crt')}`, + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, `--server.ssl.redirectHttpFromPort=${redirectPort}`, ], }, diff --git a/test/server_integration/http/ssl_with_p12/config.js b/test/server_integration/http/ssl_with_p12/config.js new file mode 100644 index 00000000000000..e220914af54f44 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12/config.js @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'fs'; +import { CA_CERT_PATH, KBN_P12_PATH, KBN_P12_PASSWORD } from '@kbn/dev-utils'; +import { createKibanaSupertestProvider } from '../../services'; + +export default async function({ readConfigFile }) { + const httpConfig = await readConfigFile(require.resolve('../../config')); + + return { + testFiles: [require.resolve('./')], + services: { + ...httpConfig.get('services'), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA_CERT_PATH)], + }), + }, + servers: { + ...httpConfig.get('servers'), + kibana: { + ...httpConfig.get('servers.kibana'), + protocol: 'https', + }, + }, + junit: { + reportName: 'Http SSL Integration Tests', + }, + esTestCluster: httpConfig.get('esTestCluster'), + kbnTestServer: { + ...httpConfig.get('kbnTestServer'), + serverArgs: [ + ...httpConfig.get('kbnTestServer.serverArgs'), + '--server.ssl.enabled=true', + `--server.ssl.keystore.path=${KBN_P12_PATH}`, + `--server.ssl.keystore.password=${KBN_P12_PASSWORD}`, + ], + }, + }; +} diff --git a/typings/encode_uri_query.d.ts b/test/server_integration/http/ssl_with_p12/index.js similarity index 75% rename from typings/encode_uri_query.d.ts rename to test/server_integration/http/ssl_with_p12/index.js index 4bfc5546244463..700f30ddc21a95 100644 --- a/typings/encode_uri_query.d.ts +++ b/test/server_integration/http/ssl_with_p12/index.js @@ -17,8 +17,12 @@ * under the License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; +export default function({ getService }) { + const supertest = getService('supertest'); + + describe('kibana server with ssl', () => { + it('handles requests using ssl with a P12 keystore', async () => { + await supertest.get('/').expect(302); + }); + }); } diff --git a/test/server_integration/http/ssl_with_p12_intermediate/config.js b/test/server_integration/http/ssl_with_p12_intermediate/config.js new file mode 100644 index 00000000000000..73a77425ec7741 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12_intermediate/config.js @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'fs'; +import { CA1_CERT_PATH, CA2_CERT_PATH, EE_P12_PATH, EE_P12_PASSWORD } from '../../__fixtures__'; +import { createKibanaSupertestProvider } from '../../services'; + +export default async function({ readConfigFile }) { + const httpConfig = await readConfigFile(require.resolve('../../config')); + + return { + testFiles: [require.resolve('./')], + services: { + ...httpConfig.get('services'), + supertest: createKibanaSupertestProvider({ + certificateAuthorities: [readFileSync(CA1_CERT_PATH), readFileSync(CA2_CERT_PATH)], + }), + }, + servers: { + ...httpConfig.get('servers'), + kibana: { + ...httpConfig.get('servers.kibana'), + protocol: 'https', + }, + }, + junit: { + reportName: 'Http SSL Integration Tests', + }, + esTestCluster: httpConfig.get('esTestCluster'), + kbnTestServer: { + ...httpConfig.get('kbnTestServer'), + serverArgs: [ + ...httpConfig.get('kbnTestServer.serverArgs'), + '--server.ssl.enabled=true', + `--server.ssl.keystore.path=${EE_P12_PATH}`, + `--server.ssl.keystore.password=${EE_P12_PASSWORD}`, + ], + }, + }; +} diff --git a/test/server_integration/http/ssl_with_p12_intermediate/index.js b/test/server_integration/http/ssl_with_p12_intermediate/index.js new file mode 100644 index 00000000000000..fb079a4e091c38 --- /dev/null +++ b/test/server_integration/http/ssl_with_p12_intermediate/index.js @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export default function({ getService }) { + const supertest = getService('supertest'); + + describe('kibana server with ssl', () => { + it('handles requests using ssl with a P12 keystore that uses an intermediate CA', async () => { + await supertest.get('/').expect(302); + }); + }); +} diff --git a/test/server_integration/services/index.js b/test/server_integration/services/index.js index 32a11f30b86835..4904bfc9eeef63 100644 --- a/test/server_integration/services/index.js +++ b/test/server_integration/services/index.js @@ -18,7 +18,7 @@ */ export { - KibanaSupertestProvider, + createKibanaSupertestProvider, KibanaSupertestWithoutAuthProvider, ElasticsearchSupertestProvider, } from './supertest'; diff --git a/test/server_integration/services/supertest.js b/test/server_integration/services/supertest.js index c09932b0207ac6..74bb5400bc299d 100644 --- a/test/server_integration/services/supertest.js +++ b/test/server_integration/services/supertest.js @@ -17,25 +17,19 @@ * under the License. */ -import { readFileSync } from 'fs'; import { format as formatUrl } from 'url'; import supertestAsPromised from 'supertest-as-promised'; -export function KibanaSupertestProvider({ getService }, options) { - const config = getService('config'); - const kibanaServerUrl = options ? formatUrl(options) : formatUrl(config.get('servers.kibana')); - - const kibanaServerCert = config - .get('kbnTestServer.serverArgs') - .filter(arg => arg.startsWith('--server.ssl.certificate')) - .map(arg => arg.split('=').pop()) - .map(path => readFileSync(path)) - .shift(); +export function createKibanaSupertestProvider({ certificateAuthorities, options } = {}) { + return function({ getService }) { + const config = getService('config'); + const kibanaServerUrl = options ? formatUrl(options) : formatUrl(config.get('servers.kibana')); - return kibanaServerCert - ? supertestAsPromised.agent(kibanaServerUrl, { ca: kibanaServerCert }) - : supertestAsPromised(kibanaServerUrl); + return certificateAuthorities + ? supertestAsPromised.agent(kibanaServerUrl, { ca: certificateAuthorities }) + : supertestAsPromised(kibanaServerUrl); + }; } export function KibanaSupertestWithoutAuthProvider({ getService }) { diff --git a/webpackShims/moment-timezone.js b/webpackShims/moment-timezone.js deleted file mode 100644 index d5e032ff21eef5..00000000000000 --- a/webpackShims/moment-timezone.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -var moment = (module.exports = require('../node_modules/moment-timezone/moment-timezone')); -moment.tz.load(require('../node_modules/moment-timezone/data/packed/latest.json')); diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index 02904cc48e0306..f38181ce56a2f3 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -28,6 +28,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`, '^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`, '^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`, + '^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`, }, coverageDirectory: '/../target/kibana-coverage/jest', coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'], diff --git a/x-pack/legacy/plugins/alerting/README.md b/x-pack/legacy/plugins/alerting/README.md index 33679cf6fa4227..30d34bd3b436d6 100644 --- a/x-pack/legacy/plugins/alerting/README.md +++ b/x-pack/legacy/plugins/alerting/README.md @@ -63,6 +63,13 @@ This is the primary function for an alert type. Whenever the alert needs to exec |previousStartedAt|The previous date and time the alert type started a successful execution.| |params|Parameters for the execution. This is where the parameters you require will be passed in. (example threshold). Use alert type validation to ensure values are set before execution.| |state|State returned from previous execution. This is the alert level state. What the executor returns will be serialized and provided here at the next execution.| +|alertId|The id of this alert.| +|spaceId|The id of the space of this alert.| +|namespace|The namespace of the space of this alert; same as spaceId, unless spaceId === 'default', then namespace = undefined.| +|name|The name of this alert.| +|tags|The tags associated with this alert.| +|createdBy|The userid that created this alert.| +|updatedBy|The userid that last updated this alert.| ### Example diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index 10627c655eca86..87fa33a9cea587 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -75,6 +75,10 @@ describe('Task Runner', () => { enabled: true, alertTypeId: '123', schedule: { interval: '10s' }, + name: 'alert-name', + tags: ['alert-', '-tags'], + createdBy: 'alert-creator', + updatedBy: 'alert-updater', mutedInstanceIds: [], params: { bar: true, @@ -138,6 +142,10 @@ describe('Task Runner', () => { `); expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); + expect(call.name).toBe('alert-name'); + expect(call.tags).toEqual(['alert-', '-tags']); + expect(call.createdBy).toBe('alert-creator'); + expect(call.updatedBy).toBe('alert-updater'); expect(call.services.alertInstanceFactory).toBeTruthy(); expect(call.services.callCluster).toBeTruthy(); expect(call.services).toBeTruthy(); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 2347e9e608ed9f..42c332e82e0343 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -13,7 +13,7 @@ import { createExecutionHandler } from './create_execution_handler'; import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; -import { AlertType, RawAlert, IntervalSchedule, Services, State } from '../types'; +import { AlertType, RawAlert, IntervalSchedule, Services, State, AlertInfoParams } from '../types'; import { promiseResult, map } from '../lib/result_type'; type AlertInstances = Record; @@ -118,13 +118,25 @@ export class TaskRunner { async executeAlertInstances( services: Services, - { params, throttle, muteAll, mutedInstanceIds }: SavedObject['attributes'], - executionHandler: ReturnType + alertInfoParams: AlertInfoParams, + executionHandler: ReturnType, + spaceId: string ): Promise { + const { + params, + throttle, + muteAll, + mutedInstanceIds, + name, + tags, + createdBy, + updatedBy, + } = alertInfoParams; const { params: { alertId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, } = this.taskInstance; + const namespace = this.context.spaceIdToNamespace(spaceId); const alertInstances = mapValues( alertRawInstances, @@ -141,6 +153,12 @@ export class TaskRunner { state: alertTypeState, startedAt: this.taskInstance.startedAt!, previousStartedAt, + spaceId, + namespace, + name, + tags, + createdBy, + updatedBy, }); // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object @@ -175,7 +193,7 @@ export class TaskRunner { async validateAndRunAlert( services: Services, apiKey: string | null, - attributes: SavedObject['attributes'], + attributes: RawAlert, references: SavedObject['references'] ) { const { @@ -191,7 +209,12 @@ export class TaskRunner { attributes.actions, references ); - return this.executeAlertInstances(services, { ...attributes, params }, executionHandler); + return this.executeAlertInstances( + services, + { ...attributes, params }, + executionHandler, + spaceId + ); } async run() { diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 9b03f9b02aa0af..def86cd46e5909 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -32,6 +32,12 @@ export interface AlertExecutorOptions { services: AlertServices; params: Record; state: State; + spaceId: string; + namespace?: string; + name: string; + tags: string[]; + createdBy: string | null; + updatedBy: string | null; } export interface AlertType { @@ -108,6 +114,18 @@ export interface RawAlert extends SavedObjectAttributes { mutedInstanceIds: string[]; } +export type AlertInfoParams = Pick< + RawAlert, + | 'params' + | 'throttle' + | 'muteAll' + | 'mutedInstanceIds' + | 'name' + | 'tags' + | 'createdBy' + | 'updatedBy' +>; + export interface AlertingPlugin { setup: PluginSetupContract; start: PluginStartContract; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx index f7166bd0cec5c7..5b7ddc2b450d60 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx @@ -12,10 +12,11 @@ import { i18n } from '@kbn/i18n'; import { CytoscapeContext } from './Cytoscape'; import { FullscreenPanel } from './FullscreenPanel'; -const Container = styled('div')` +const ControlsContainer = styled('div')` left: ${theme.gutterTypes.gutterMedium}; position: absolute; top: ${theme.gutterTypes.gutterSmall}; + z-index: 1; /* The element containing the cytoscape canvas has z-index = 0. */ `; const Button = styled(EuiButtonIcon)` @@ -83,7 +84,7 @@ export function Controls() { }); return ( - + - + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 16a91116ae762b..cc09975a344b5e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -57,7 +57,8 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { const elements = Array.isArray(data) ? data : []; const license = useLicense(); const isValidPlatinumLicense = - license?.isActive && license?.type === 'platinum'; + license?.isActive && + (license?.type === 'platinum' || license?.type === 'trial'); return isValidPlatinumLicense ? ( } > - +
))} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js index 99eb17386f847b..c4d16c9fcf7ddc 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js @@ -129,7 +129,7 @@ export default function Legends({ } indicator={() => (
- +
)} disabled={!showAnnotations} diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx index 8cdb7f050027dc..0bd38967826034 100644 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx @@ -16,8 +16,8 @@ export const LicenseContext = React.createContext( export function LicenseProvider({ children }: { children: React.ReactChild }) { const { license$ } = useApmPluginContext().plugins.licensing; - const license = useObservable(license$); - const hasInvalidLicense = !license?.isActive; + const license = useObservable(license$, { isActive: true } as ILicense); + const hasInvalidLicense = !license.isActive; // if license is invalid show an error message if (hasInvalidLicense) { diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js deleted file mode 100644 index e397bda763f1ab..00000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getColorsFromPalette } from '../../lib/get_colors_from_palette'; -import { - grayscalePalette, - gradientPalette, -} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; - -describe('getColorsFromPalette', () => { - it('returns the array of colors from a palette object when gradient is false', () => { - expect(getColorsFromPalette(grayscalePalette, 20)).to.eql(grayscalePalette.colors); - }); - - it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { - const result = getColorsFromPalette(gradientPalette, 16); - expect(result) - .to.have.length(16) - .and.to.eql([ - '#ffffff', - '#eeeeee', - '#dddddd', - '#cccccc', - '#bbbbbb', - '#aaaaaa', - '#999999', - '#888888', - '#777777', - '#666666', - '#555555', - '#444444', - '#333333', - '#222222', - '#111111', - '#000000', - ]); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js deleted file mode 100644 index ba43db7a83677f..00000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getLegendConfig } from '../get_legend_config'; - -describe('getLegendConfig', () => { - describe('show', () => { - it('hides the legend', () => { - expect(getLegendConfig(false, 2)) - .to.only.have.key('show') - .and.to.have.property('show', false); - expect(getLegendConfig(false, 10)) - .to.only.have.key('show') - .and.to.have.property('show', false); - }); - - it('hides the legend when there are less than 2 series', () => { - expect(getLegendConfig(false, 1)) - .to.only.have.key('show') - .and.to.have.property('show', false); - expect(getLegendConfig(true, 1)) - .to.only.have.key('show') - .and.to.have.property('show', false); - }); - - it('shows the legend when there are two or more series', () => { - expect(getLegendConfig('sw', 2)).to.have.property('show', true); - expect(getLegendConfig(true, 5)).to.have.property('show', true); - }); - }); - - describe('position', () => { - it('sets the position of the legend', () => { - expect(getLegendConfig('nw')).to.have.property('position', 'nw'); - expect(getLegendConfig('ne')).to.have.property('position', 'ne'); - expect(getLegendConfig('sw')).to.have.property('position', 'sw'); - expect(getLegendConfig('se')).to.have.property('position', 'se'); - }); - - it("defaults to 'ne'", () => { - expect(getLegendConfig(true)).to.have.property('position', 'ne'); - expect(getLegendConfig('foo')).to.have.property('position', 'ne'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts index 616e45c86c4afc..88bb32c846c245 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts @@ -7,184 +7,195 @@ jest.mock('ui/new_platform'); import { functionSpecs } from '../../__tests__/fixtures/function_specs'; -import { getAutocompleteSuggestions } from './autocomplete'; - -describe('getAutocompleteSuggestions', () => { - it('should suggest functions', () => { - const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); - expect(suggestions.length).toBe(functionSpecs.length); - expect(suggestions[0].start).toBe(0); - expect(suggestions[0].end).toBe(0); - }); - - it('should suggest functions filtered by text', () => { - const expression = 'pl'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); - const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); - expect(nonmatching.length).toBe(0); - expect(suggestions[0].start).toBe(0); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest arguments', () => { - const expression = 'plot '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const plotFn = functionSpecs.find(spec => spec.name === 'plot'); - expect(suggestions.length).toBe(Object.keys(plotFn.args).length); - expect(suggestions[0].start).toBe(expression.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest arguments filtered by text', () => { - const expression = 'plot axis'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const plotFn = functionSpecs.find(spec => spec.name === 'plot'); - const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); - expect(suggestions.length).toBe(matchingArgs.length); - expect(suggestions[0].start).toBe('plot '.length); - expect(suggestions[0].end).toBe('plot axis'.length); - }); - - it('should suggest values', () => { - const expression = 'shape shape='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - expect(suggestions.length).toBe(shapeFn.args.shape.options.length); - expect(suggestions[0].start).toBe(expression.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest values filtered by text', () => { - const expression = 'shape shape=ar'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); - expect(suggestions.length).toBe(matchingValues.length); - expect(suggestions[0].start).toBe(expression.length - 'ar'.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest functions inside an expression', () => { - const expression = 'if {}'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - expect(suggestions.length).toBe(functionSpecs.length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest arguments inside an expression', () => { - const expression = 'if {lt }'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const ltFn = functionSpecs.find(spec => spec.name === 'lt'); - expect(suggestions.length).toBe(Object.keys(ltFn.args).length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest values inside an expression', () => { - const expression = 'if {shape shape=}'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - expect(suggestions.length).toBe(shapeFn.args.shape.options.length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest values inside quotes', () => { - const expression = 'shape shape="ar"'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); - expect(suggestions.length).toBe(matchingValues.length); - expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should prioritize functions that start with text', () => { - const expression = 't'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); - const alterColumnIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('alterColumn') - ); - expect(tableIndex).toBeLessThan(alterColumnIndex); - }); - - it('should prioritize functions that match the previous function type', () => { - const expression = 'plot | '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); - const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); - expect(renderIndex).toBeLessThan(anyIndex); - }); - - it('should alphabetize functions', () => { - const expression = ''; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); - const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); - expect(anyIndex).toBeLessThan(metricIndex); - }); - - it('should prioritize arguments that start with text', () => { - const expression = 'plot y'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); - const defaultStyleIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('defaultStyle') - ); - expect(yaxisIndex).toBeLessThan(defaultStyleIndex); - }); - - it('should prioritize unnamed arguments', () => { - const expression = 'case '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); - const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); - expect(whenIndex).toBeLessThan(thenIndex); - }); - - it('should alphabetize arguments', () => { - const expression = 'plot '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); - const defaultStyleIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('defaultStyle') - ); - expect(defaultStyleIndex).toBeLessThan(yaxisIndex); - }); - - it('should quote string values', () => { - const expression = 'shape shape='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toMatch(/^".*"$/); - }); - - it('should not quote sub expression value suggestions', () => { - const expression = 'plot font='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toBe('{font}'); - }); - - it('should not quote booleans', () => { - const expression = 'table paginate=true'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toBe('true'); +import { getAutocompleteSuggestions, getFnArgDefAtPosition } from './autocomplete'; + +describe('autocomplete', () => { + describe('getFnArgDefAtPosition', () => { + it('should return function definition for plot', () => { + const expression = 'plot '; + const def = getFnArgDefAtPosition(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(def.fnDef).toBe(plotFn); + }); + }); + + describe('getAutocompleteSuggestions', () => { + it('should suggest functions', () => { + const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(0); + }); + + it('should suggest functions filtered by text', () => { + const expression = 'pl'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); + const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); + expect(nonmatching.length).toBe(0); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(suggestions.length).toBe(Object.keys(plotFn.args).length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest arguments filtered by text', () => { + const expression = 'plot axis'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); + expect(suggestions.length).toBe(matchingArgs.length); + expect(suggestions[0].start).toBe('plot '.length); + expect(suggestions[0].end).toBe('plot axis'.length); + }); + + it('should suggest values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest values filtered by text', () => { + const expression = 'shape shape=ar'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - 'ar'.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest functions inside an expression', () => { + const expression = 'if {}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest arguments inside an expression', () => { + const expression = 'if {lt }'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const ltFn = functionSpecs.find(spec => spec.name === 'lt'); + expect(suggestions.length).toBe(Object.keys(ltFn.args).length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest values inside an expression', () => { + const expression = 'if {shape shape=}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest values inside quotes', () => { + const expression = 'shape shape="ar"'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should prioritize functions that start with text', () => { + const expression = 't'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); + const alterColumnIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('alterColumn') + ); + expect(tableIndex).toBeLessThan(alterColumnIndex); + }); + + it('should prioritize functions that match the previous function type', () => { + const expression = 'plot | '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(renderIndex).toBeLessThan(anyIndex); + }); + + it('should alphabetize functions', () => { + const expression = ''; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(anyIndex).toBeLessThan(metricIndex); + }); + + it('should prioritize arguments that start with text', () => { + const expression = 'plot y'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(yaxisIndex).toBeLessThan(defaultStyleIndex); + }); + + it('should prioritize unnamed arguments', () => { + const expression = 'case '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); + const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); + expect(whenIndex).toBeLessThan(thenIndex); + }); + + it('should alphabetize arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(defaultStyleIndex).toBeLessThan(yaxisIndex); + }); + + it('should quote string values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toMatch(/^".*"$/); + }); + + it('should not quote sub expression value suggestions', () => { + const expression = 'plot font='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toBe('{font}'); + }); + + it('should not quote booleans', () => { + const expression = 'table paginate=true'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toBe('true'); + }); }); }); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts similarity index 77% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts index acd9e6d1821d60..8bfe723bc2ae00 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isValidDataUrl, parseDataUrl } from '../dataurl'; +import { isValidDataUrl, parseDataUrl } from './dataurl'; const BASE64_TEXT = 'data:text/plain;charset=utf-8;base64,VGhpcyBpcyBhIHRlc3Q='; const BASE64_SVG = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; const BASE64_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk8PxfDwADYgHJvQ16TAAAAABJRU5ErkJggg=='; +const INVALID_BASE64_PIXEL = + 'data:image/png;%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%01%00%00%00%01%08%06%00%00%00%1F%15%C4%89%0'; const RAW_TEXT = 'data:text/plain;charset=utf-8,This%20is%20a%20test'; const RAW_SVG = @@ -20,10 +22,13 @@ const RAW_PIXEL = describe('dataurl', () => { describe('isValidDataUrl', () => { - test('invalid data url', () => { + it('returns false for an invalid data url', () => { expect(isValidDataUrl('somestring')).toBe(false); }); - test('valid data urls', () => { + it('returns false for an empty string', () => { + expect(isValidDataUrl('')).toBe(false); + }); + it('returns true for valid data urls', () => { expect(isValidDataUrl(BASE64_TEXT)).toBe(true); expect(isValidDataUrl(BASE64_SVG)).toBe(true); expect(isValidDataUrl(BASE64_PIXEL)).toBe(true); @@ -34,10 +39,13 @@ describe('dataurl', () => { }); describe('dataurl.parseDataUrl', () => { - test('invalid data url', () => { + it('returns null for an invalid data url', () => { expect(parseDataUrl('somestring')).toBeNull(); }); - test('text data urls', () => { + it('returns null for an invalid base64 image', () => { + expect(parseDataUrl(INVALID_BASE64_PIXEL)).toBeNull(); + }); + it('returns correct values for text data urls', () => { expect(parseDataUrl(BASE64_TEXT)).toEqual({ charset: 'utf-8', data: null, @@ -55,7 +63,7 @@ describe('dataurl', () => { mimetype: 'text/plain', }); }); - test('png data urls', () => { + it('returns correct values for png data urls', () => { expect(parseDataUrl(RAW_PIXEL)).toBeNull(); expect(parseDataUrl(BASE64_PIXEL)).toEqual({ charset: undefined, @@ -66,7 +74,7 @@ describe('dataurl', () => { mimetype: 'image/png', }); }); - test('svg data urls', () => { + it('returns correct values for svg data urls', () => { expect(parseDataUrl(RAW_SVG)).toEqual({ charset: undefined, data: null, diff --git a/x-pack/legacy/plugins/canvas/common/lib/errors.test.js b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js new file mode 100644 index 00000000000000..a589fde5dadb66 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RenderError } from './errors'; + +describe('errors', () => { + it('creates a test error', () => { + // eslint-disable-next-line new-cap + const throwTestError = () => RenderError(); + expect(throwTestError.name).toBe('throwTestError'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js new file mode 100644 index 00000000000000..ae46661b50cd24 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFormHandlers } from './expression_form_handlers'; + +describe('ExpressionFormHandlers', () => { + it('executes destroy function', () => { + const handler = new ExpressionFormHandlers(); + handler.onDestroy(() => { + return 'DESTROYED!'; + }); + expect(handler.destroy()).toBe('DESTROYED!'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts new file mode 100644 index 00000000000000..d06c2af4f062ab --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fetch, arrayBufferFetch } from './fetch'; + +describe('fetch', () => { + it('test fetch headers', () => { + expect(fetch.defaults.headers.Accept).toBe('application/json'); + expect(fetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(fetch.defaults.headers['kbn-xsrf']).toBe('professionally-crafted-string-of-text'); + }); + + it('test arrayBufferFetch headers', () => { + expect(arrayBufferFetch.defaults.headers.Accept).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['kbn-xsrf']).toBe( + 'professionally-crafted-string-of-text' + ); + expect(arrayBufferFetch.defaults.responseType).toBe('arraybuffer'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js new file mode 100644 index 00000000000000..ebc72db1f67f0f --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + grayscalePalette, + gradientPalette, +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { getColorsFromPalette } from './get_colors_from_palette'; + +describe('getColorsFromPalette', () => { + it('returns the array of colors from a palette object when gradient is false', () => { + expect(getColorsFromPalette(grayscalePalette, 20)).toBe(grayscalePalette.colors); + }); + + it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { + const result = getColorsFromPalette(gradientPalette, 16); + expect(result).toEqual([ + '#ffffff', + '#eeeeee', + '#dddddd', + '#cccccc', + '#bbbbbb', + '#aaaaaa', + '#999999', + '#888888', + '#777777', + '#666666', + '#555555', + '#444444', + '#333333', + '#222222', + '#111111', + '#000000', + ]); + expect(result).toHaveLength(16); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts similarity index 87% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts index 34cfbb5a2befb9..82e724c33ecc81 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getFieldType } from '../get_field_type'; import { emptyTable, testTable, -} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +import { getFieldType } from './get_field_type'; describe('getFieldType', () => { it('returns type of a field in a datatable', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js new file mode 100644 index 00000000000000..b9ab9ae6aba546 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getLegendConfig } from './get_legend_config'; + +describe('getLegendConfig', () => { + describe('show', () => { + it('hides the legend', () => { + expect(getLegendConfig(false, 2)).toHaveProperty('show', false); + expect(getLegendConfig(false, 10)).toHaveProperty('show', false); + }); + + it('hides the legend when there are less than 2 series', () => { + expect(getLegendConfig(false, 1)).toHaveProperty('show', false); + expect(getLegendConfig(true, 1)).toHaveProperty('show', false); + }); + + it('shows the legend when there are two or more series', () => { + expect(getLegendConfig('sw', 2)).toHaveProperty('show', true); + expect(getLegendConfig(true, 5)).toHaveProperty('show', true); + }); + }); + + describe('position', () => { + it('sets the position of the legend', () => { + expect(getLegendConfig('nw')).toHaveProperty('position', 'nw'); + expect(getLegendConfig('ne')).toHaveProperty('position', 'ne'); + expect(getLegendConfig('sw')).toHaveProperty('position', 'sw'); + expect(getLegendConfig('se')).toHaveProperty('position', 'se'); + }); + + it("defaults to 'ne'", () => { + expect(getLegendConfig(true)).toHaveProperty('position', 'ne'); + expect(getLegendConfig('foo')).toHaveProperty('position', 'ne'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js new file mode 100644 index 00000000000000..5fcb2d42395fa3 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { testTable } from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +import { Handlebars } from './handlebars'; + +describe('handlebars', () => { + it('registers math function and returns argument error', () => { + const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}"); + expect(template()).toBe('test math: MATH ERROR: first argument must be an array'); + }); + it('evaluates math function successfully', () => { + const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}"); + expect(template(testTable)).toBe('test math: 82164.33'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts similarity index 80% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts index d9aa56314948e9..00b4b40fa98391 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts @@ -4,21 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hexToRgb } from '../hex_to_rgb'; +import { hexToRgb } from './hex_to_rgb'; describe('hexToRgb', () => { - test('invalid hex', () => { + it('returns null for an invalid hex', () => { expect(hexToRgb('hexadecimal')).toBeNull(); expect(hexToRgb('#00')).toBeNull(); expect(hexToRgb('#00000')).toBeNull(); }); - test('shorthand', () => { + it('returns correct value for shorthand hex codes', () => { expect(hexToRgb('#000')).toEqual([0, 0, 0]); expect(hexToRgb('#FFF')).toEqual([255, 255, 255]); expect(hexToRgb('#fff')).toEqual([255, 255, 255]); expect(hexToRgb('#fFf')).toEqual([255, 255, 255]); }); - test('longhand', () => { + it('returns correct value for longhand hex codes', () => { expect(hexToRgb('#000000')).toEqual([0, 0, 0]); expect(hexToRgb('#ffffff')).toEqual([255, 255, 255]); expect(hexToRgb('#fffFFF')).toEqual([255, 255, 255]); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts similarity index 97% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts index 2a7cef7cf42365..65bc2469647aa9 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isValidHttpUrl } from '../httpurl'; +import { isValidHttpUrl } from './httpurl'; describe('httpurl.isValidHttpUrl', () => { it('matches HTTP URLs', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts similarity index 97% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts index 6f6d42e7129a91..faf319769cab03 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pivotObjectArray } from '../pivot_object_array'; +import { pivotObjectArray } from './pivot_object_array'; interface Car { make: string; diff --git a/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js new file mode 100644 index 00000000000000..bbbd4f51d483fa --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { missingImage } from '../../common/lib/missing_asset'; +import { resolveFromArgs, resolveWithMissingImage } from './resolve_dataurl'; + +describe('resolve_dataurl', () => { + describe('resolveFromArgs', () => { + it('finds and returns the dataurl from args successfully', () => { + const args = { + name: 'dataurl', + argType: 'imageUpload', + dataurl: [missingImage, 'test2'], + }; + expect(resolveFromArgs(args)).toBe(missingImage); + }); + it('finds and returns null for invalid dataurl', () => { + const args = { + name: 'dataurl', + argType: 'imageUpload', + dataurl: ['invalid url', 'test2'], + }; + expect(resolveFromArgs(args)).toBe(null); + }); + }); + + describe('resolveWithMissingImage', () => { + it('returns valid dataurl', () => { + expect(resolveWithMissingImage(missingImage)).toBe(missingImage); + }); + it('returns missingImage for invalid dataurl', () => { + expect(resolveWithMissingImage('invalid dataurl')).toBe(missingImage); + }); + it('returns null for null dataurl', () => { + expect(resolveWithMissingImage(null)).toBe(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts similarity index 94% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts index e67e46f9e5dacd..d6eeb9392f40f1 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { unquoteString } from '../unquote_string'; +import { unquoteString } from './unquote_string'; describe('unquoteString', () => { it('removes double quotes', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/url.test.js b/x-pack/legacy/plugins/canvas/common/lib/url.test.js new file mode 100644 index 00000000000000..d49d12c6bf3828 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/url.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { missingImage } from '../../common/lib/missing_asset'; +import { isValidUrl } from './url'; + +describe('resolve_dataurl', () => { + it('returns valid dataurl', () => { + expect(isValidUrl(missingImage)).toBe(true); + }); + it('returns valid http url', () => { + const httpurl = 'https://test.com/s/'; + expect(isValidUrl(httpurl)).toBe(true); + }); + it('returns false for invalid url', () => { + expect(isValidUrl('test')).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts index e16a2ac67a54e0..589dd9b1dad873 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_column.ts @@ -14,7 +14,7 @@ export const help: FunctionHelp> = { help: i18n.translate('xpack.canvas.functions.mapColumnHelpText', { defaultMessage: 'Adds a column calculated as the result of other columns. ' + - 'Changes are made only when you provide arguments.' + + 'Changes are made only when you provide arguments. ' + 'See also {mapColumnFn} and {staticColumnFn}.', values: { mapColumnFn: '`mapColumn`', diff --git a/x-pack/legacy/plugins/canvas/webpackShims/moment.js b/x-pack/legacy/plugins/canvas/webpackShims/moment.js deleted file mode 100644 index 1261aa7f7bd0fa..00000000000000 --- a/x-pack/legacy/plugins/canvas/webpackShims/moment.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -module.exports = require('../../../node_modules/moment/min/moment.min.js'); diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx index 2f38f68b01f16d..d7cbcf59213be8 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx @@ -33,6 +33,7 @@ describe('field_manager', () => { selected: true, type: 'string', hopSize: 5, + aggregatable: true, }, { name: 'field2', @@ -42,6 +43,7 @@ describe('field_manager', () => { type: 'string', hopSize: 0, lastValidHopSize: 5, + aggregatable: false, }, { name: 'field3', @@ -50,6 +52,16 @@ describe('field_manager', () => { selected: false, type: 'string', hopSize: 5, + aggregatable: true, + }, + { + name: 'field4', + color: 'orange', + icon: getSuitableIcon('field4'), + selected: false, + type: 'string', + hopSize: 5, + aggregatable: false, }, ]) ); @@ -86,6 +98,17 @@ describe('field_manager', () => { ).toEqual('field2'); }); + it('should show selected non-aggregatable fields in picker, but hide unselected ones', () => { + expect( + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('options') + .map((option: { label: string }) => option.label) + ).toEqual(['field1', 'field2', 'field3']); + }); + it('should select fields from picker', () => { expect( getInstance() @@ -130,6 +153,25 @@ describe('field_manager', () => { expect(getInstance().find(FieldEditor).length).toEqual(1); }); + it('should show remove non-aggregatable fields from picker after deselection', () => { + act(() => { + getInstance() + .find(FieldEditor) + .at(1) + .dive() + .find(EuiContextMenu) + .prop('panels')![0].items![2].onClick!({} as any); + }); + expect( + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('options') + .map((option: { label: string }) => option.label) + ).toEqual(['field1', 'field3']); + }); + it('should disable field', () => { const toggleItem = getInstance() .find(FieldEditor) diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx index 6ad792defb6697..b38e3f8430980e 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx @@ -114,9 +114,26 @@ export function FieldPicker({ function toOptions( fields: WorkspaceField[] ): Array<{ label: string; checked?: 'on' | 'off'; prepend?: ReactNode }> { - return fields.map(field => ({ - label: field.name, - prepend: , - checked: field.selected ? 'on' : undefined, - })); + return ( + fields + // don't show non-aggregatable fields, except for the case when they are already selected. + // this is necessary to ensure backwards compatibility with existing workspaces that might + // contain non-aggregatable fields. + .filter(field => isExplorable(field) || field.selected) + .map(field => ({ + label: field.name, + prepend: , + checked: field.selected ? 'on' : undefined, + })) + ); +} + +const explorableTypes = ['string', 'number', 'date', 'ip', 'boolean']; + +function isExplorable(field: WorkspaceField) { + if (!field.aggregatable) { + return false; + } + + return explorableTypes.includes(field.type); } diff --git a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx index a615901f40e253..0109e1f5a5ac7b 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx @@ -112,6 +112,7 @@ describe('settings', () => { code: '1', label: 'test', }, + aggregatable: true, }, { selected: false, @@ -123,6 +124,7 @@ describe('settings', () => { code: '1', label: 'test', }, + aggregatable: true, }, ]) ); diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts index 3bfc868fcb06e9..79ff4debc7e825 100644 --- a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts @@ -13,8 +13,24 @@ describe('fetch_top_nodes', () => { it('should build terms agg', async () => { const postMock = jest.fn(() => Promise.resolve({ resp: {} })); await fetchTopNodes(postMock as any, 'test', [ - { color: '', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, - { color: '', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + { + color: '', + hopSize: 5, + icon, + name: 'field1', + selected: false, + type: 'string', + aggregatable: true, + }, + { + color: '', + hopSize: 5, + icon, + name: 'field2', + selected: false, + type: 'string', + aggregatable: true, + }, ]); expect(postMock).toHaveBeenCalledWith('../api/graph/searchProxy', { body: JSON.stringify({ @@ -65,8 +81,24 @@ describe('fetch_top_nodes', () => { }) ); const result = await fetchTopNodes(postMock as any, 'test', [ - { color: 'red', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, - { color: 'blue', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + { + color: 'red', + hopSize: 5, + icon, + name: 'field1', + selected: false, + type: 'string', + aggregatable: true, + }, + { + color: 'blue', + hopSize: 5, + icon, + name: 'field2', + selected: false, + type: 'string', + aggregatable: true, + }, ]); expect(result.length).toEqual(4); expect(result[0]).toEqual({ diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts index d38c950a5986f2..1861479f85f189 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts @@ -119,9 +119,9 @@ describe('deserialize', () => { savedWorkspace, { getNonScriptedFields: () => [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'string' }, - { name: 'field3', type: 'string' }, + { name: 'field1', type: 'string', aggregatable: true }, + { name: 'field2', type: 'string', aggregatable: true }, + { name: 'field3', type: 'string', aggregatable: true }, ], } as IndexPattern, workspace @@ -140,6 +140,7 @@ describe('deserialize', () => { expect(allFields).toMatchInlineSnapshot(` Array [ Object { + "aggregatable": true, "color": "black", "hopSize": undefined, "icon": undefined, @@ -149,6 +150,7 @@ describe('deserialize', () => { "type": "string", }, Object { + "aggregatable": true, "color": "black", "hopSize": undefined, "icon": undefined, @@ -158,6 +160,7 @@ describe('deserialize', () => { "type": "string", }, Object { + "aggregatable": true, "color": "#CE0060", "hopSize": 5, "icon": Object { diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts index af34b4f1a725b4..43425077cc174d 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts @@ -89,6 +89,7 @@ export function mapFields(indexPattern: IndexPattern): WorkspaceField[] { color: colorChoices[index % colorChoices.length], selected: false, type: field.type, + aggregatable: Boolean(field.aggregatable), })) .sort((a, b) => { if (a.name < b.name) { diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts index 0e0c750383a71e..a3942eccfdac36 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts @@ -41,6 +41,7 @@ describe('serialize', () => { name: 'field1', selected: true, type: 'string', + aggregatable: true, }, { color: 'black', @@ -48,6 +49,7 @@ describe('serialize', () => { name: 'field2', selected: true, type: 'string', + aggregatable: true, }, ], selectedIndex: { diff --git a/x-pack/legacy/plugins/graph/public/types/app_state.ts b/x-pack/legacy/plugins/graph/public/types/app_state.ts index eef8060f07f5cd..876f2cf23b53a9 100644 --- a/x-pack/legacy/plugins/graph/public/types/app_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/app_state.ts @@ -25,6 +25,7 @@ export interface WorkspaceField { icon: FontawesomeIcon; selected: boolean; type: string; + aggregatable: boolean; } export interface AdvancedSettings { diff --git a/x-pack/legacy/plugins/graph/public/types/persistence.ts b/x-pack/legacy/plugins/graph/public/types/persistence.ts index 7fc5e15d9ea72b..adb07605b61c46 100644 --- a/x-pack/legacy/plugins/graph/public/types/persistence.ts +++ b/x-pack/legacy/plugins/graph/public/types/persistence.ts @@ -37,7 +37,7 @@ export interface SerializedUrlTemplate extends Omit { +export interface SerializedField extends Omit { iconClass: string; } diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 9463eccb93a022..2c0ea7fe699b80 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -20,8 +20,8 @@ import sinon from 'sinon'; import { findTestSubject } from '@elastic/eui/lib/test'; import { positiveNumbersAboveZeroErrorMessage, - numberRequiredMessage, positiveNumberRequiredMessage, + numberRequiredMessage, maximumAgeRequiredMessage, maximumSizeRequiredMessage, policyNameRequiredMessage, @@ -243,17 +243,18 @@ describe('edit policy', () => { noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', ''); save(rendered); expectedErrorMessages(rendered, [numberRequiredMessage]); }); - test('should show positive number required above zero error when trying to save warm phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); setPhaseAfter(rendered, 'warm', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save warm phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -383,14 +384,14 @@ describe('edit policy', () => { }); }); describe('cold phase', () => { - test('should show positive number required error when trying to save cold phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'cold'); setPhaseAfter(rendered, 'cold', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save cold phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -464,14 +465,14 @@ describe('edit policy', () => { }); }); describe('delete phase', () => { - test('should show positive number required error when trying to save delete phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'delete'); setPhaseAfter(rendered, 'delete', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', () => { const rendered = mountWithIntl(component); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js index 0ed28bbaa905f3..b4c9f4e958cd22 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js @@ -131,7 +131,7 @@ export const MinAgeInput = props => { onChange={async e => { setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); }} - min={1} + min={0} /> diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js index b0af0e6547803e..a8f7fd3f4bdfa9 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js @@ -17,7 +17,7 @@ import { export const defaultColdPhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_REPLICA_COUNT]: '', diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js index 5a44539ff90f84..b5296cd83fabdc 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js @@ -15,7 +15,7 @@ export const defaultDeletePhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', }; export const defaultEmptyDeletePhase = defaultDeletePhase; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js index d3dc55178b253d..f02ac2096675fc 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js @@ -23,7 +23,7 @@ export const defaultWarmPhase = { [PHASE_ROLLOVER_ALIAS]: '', [PHASE_FORCE_MERGE_SEGMENTS]: '', [PHASE_FORCE_MERGE_ENABLED]: false, - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_SHRINK_ENABLED]: false, diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js index 026845c78ee662..750a7feb19c3d5 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js @@ -120,12 +120,6 @@ export const validatePhase = (type, phase, errors) => { phaseErrors[numberedAttribute] = [numberRequiredMessage]; } else if (phase[numberedAttribute] < 0) { phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; - } else if ( - (numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE || - numberedAttribute === PHASE_PRIMARY_SHARD_COUNT) && - phase[numberedAttribute] < 1 - ) { - phaseErrors[numberedAttribute] = [positiveNumbersAboveZeroErrorMessage]; } } } diff --git a/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts b/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts index 46aab881bce4c4..0ef5ae82baeb9a 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/node_details_api.ts @@ -50,6 +50,6 @@ export const NodeDetailsRequestRT = rt.intersection([ ]); // export type NodeDetailsRequest = InfraWrappableRequest; - +export type NodeDetailsMetricData = rt.TypeOf; export type NodeDetailsRequest = rt.TypeOf; export type NodeDetailsMetricDataResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts b/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts index 4ee0c9e23b68fd..c7c15fd8af1610 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/snapshot_api.ts @@ -19,7 +19,7 @@ export const SnapshotNodePathRT = rt.intersection([ const SnapshotNodeMetricOptionalRT = rt.partial({ value: rt.union([rt.number, rt.null]), - average: rt.union([rt.number, rt.null]), + avg: rt.union([rt.number, rt.null]), max: rt.union([rt.number, rt.null]), }); @@ -27,8 +27,12 @@ const SnapshotNodeMetricRequiredRT = rt.type({ name: SnapshotMetricTypeRT, }); +export const SnapshotNodeMetricRT = rt.intersection([ + SnapshotNodeMetricRequiredRT, + SnapshotNodeMetricOptionalRT, +]); export const SnapshotNodeRT = rt.type({ - metric: rt.intersection([SnapshotNodeMetricRequiredRT, SnapshotNodeMetricOptionalRT]), + metric: SnapshotNodeMetricRT, path: rt.array(SnapshotNodePathRT), }); @@ -43,18 +47,24 @@ export const InfraTimerangeInputRT = rt.type({ from: rt.number, }); +export const SnapshotGroupByRT = rt.array( + rt.partial({ + label: rt.union([rt.string, rt.null]), + field: rt.union([rt.string, rt.null]), + }) +); + +export const SnapshotMetricInputRT = rt.type({ + type: SnapshotMetricTypeRT, +}); + export const SnapshotRequestRT = rt.intersection([ rt.type({ timerange: InfraTimerangeInputRT, metric: rt.type({ type: SnapshotMetricTypeRT, }), - groupBy: rt.array( - rt.partial({ - label: rt.union([rt.string, rt.null]), - field: rt.union([rt.string, rt.null]), - }) - ), + groupBy: SnapshotGroupByRT, nodeType: ItemTypeRT, sourceId: rt.string, }), @@ -65,6 +75,11 @@ export const SnapshotRequestRT = rt.intersection([ }), ]); +export type SnapshotNodePath = rt.TypeOf; +export type SnapshotMetricInput = rt.TypeOf; +export type InfraTimerangeInput = rt.TypeOf; +export type SnapshotNodeMetric = rt.TypeOf; +export type SnapshotGroupBy = rt.TypeOf; export type SnapshotRequest = rt.TypeOf; export type SnapshotNode = rt.TypeOf; export type SnapshotNodeResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx index 490b5c552dcc35..4b16728a52bece 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsEC2ToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - InfraSnapshotMetricType.diskIOReadBytes, - InfraSnapshotMetricType.diskIOWriteBytes, + const metricTypes: SnapshotMetricType[] = [ + 'cpu', + 'rx', + 'tx', + 'diskIOReadBytes', + 'diskIOWriteBytes', ]; const groupByFields = [ 'cloud.availability_zone', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx index 86ed57e8f4c7f9..e4654825a8de1a 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx @@ -6,17 +6,18 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; -import { InfraSnapshotMetricType } from '../../../public/graphql/types'; + import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsRDSToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.rdsConnections, - InfraSnapshotMetricType.rdsQueriesExecuted, - InfraSnapshotMetricType.rdsActiveTransactions, - InfraSnapshotMetricType.rdsLatency, + const metricTypes: SnapshotMetricType[] = [ + 'cpu', + 'rdsConnections', + 'rdsQueriesExecuted', + 'rdsActiveTransactions', + 'rdsLatency', ]; const groupByFields = [ 'cloud.availability_zone', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx index 276b6b83eb43df..d1aa48260db476 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx @@ -6,17 +6,17 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; -import { InfraSnapshotMetricType } from '../../../public/graphql/types'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsS3ToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.s3BucketSize, - InfraSnapshotMetricType.s3NumberOfObjects, - InfraSnapshotMetricType.s3TotalRequests, - InfraSnapshotMetricType.s3DownloadBytes, - InfraSnapshotMetricType.s3UploadBytes, + const metricTypes: SnapshotMetricType[] = [ + 's3BucketSize', + 's3NumberOfObjects', + 's3TotalRequests', + 's3DownloadBytes', + 's3UploadBytes', ]; const groupByFields = ['cloud.region']; return ( diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx index 67baa22a5e6b03..9c5ff51b2d9a21 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx @@ -7,16 +7,16 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; import { CloudToolbarItems } from '../shared/compontents/cloud_toolbar_items'; +import { SnapshotMetricType } from '../types'; export const AwsSQSToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.sqsMessagesVisible, - InfraSnapshotMetricType.sqsMessagesDelayed, - InfraSnapshotMetricType.sqsMessagesSent, - InfraSnapshotMetricType.sqsMessagesEmpty, - InfraSnapshotMetricType.sqsOldestMessage, + const metricTypes: SnapshotMetricType[] = [ + 'sqsMessagesVisible', + 'sqsMessagesDelayed', + 'sqsMessagesSent', + 'sqsMessagesEmpty', + 'sqsOldestMessage', ]; const groupByFields = ['cloud.region']; return ( diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx index 9ed2cbe6dea084..f1e7ea721cabd0 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/container/toolbar_items.tsx @@ -7,15 +7,10 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricType } from '../types'; export const ContainerToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.memory, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - ]; + const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; const groupByFields = [ 'host.name', 'cloud.availability_zone', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx index f8df81a33a8ec0..6e0563f2f555b0 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/host/toolbar_items.tsx @@ -7,17 +7,10 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricType } from '../types'; export const HostToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.memory, - InfraSnapshotMetricType.load, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - InfraSnapshotMetricType.logRate, - ]; + const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'load', 'rx', 'tx', 'logRate']; const groupByFields = [ 'cloud.availability_zone', 'cloud.machine.type', diff --git a/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx b/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx index 9ef4a889dc5891..b075f10a15680f 100644 --- a/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx +++ b/x-pack/legacy/plugins/infra/common/inventory_models/pod/toolbar_items.tsx @@ -7,15 +7,10 @@ import React from 'react'; import { ToolbarProps } from '../../../public/components/inventory/toolbars/toolbar'; import { MetricsAndGroupByToolbarItems } from '../shared/compontents/metrics_and_groupby_toolbar_items'; -import { InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricType } from '../types'; export const PodToolbarItems = (props: ToolbarProps) => { - const metricTypes = [ - InfraSnapshotMetricType.cpu, - InfraSnapshotMetricType.memory, - InfraSnapshotMetricType.rx, - InfraSnapshotMetricType.tx, - ]; + const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; const groupByFields = ['kubernetes.namespace', 'kubernetes.node.name', 'service.type']; return ( void; currentTime: number; onViewChange: (view: string) => void; @@ -30,8 +27,8 @@ export interface LayoutProps { autoBounds: boolean; filterQuery: string | null | undefined; - metric: InfraSnapshotMetricInput; - groupBy: InfraSnapshotGroupbyInput[]; + metric: SnapshotMetricInput; + groupBy: SnapshotGroupBy; sourceId: string; accountId: string; region: string; diff --git a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx index 1188e486385ea5..ee0eceee6d1570 100644 --- a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar.tsx @@ -7,13 +7,9 @@ import React, { FunctionComponent } from 'react'; import { Action } from 'typescript-fsa'; import { EuiFlexItem } from '@elastic/eui'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../../common/http_api/snapshot_api'; import { InventoryCloudAccount } from '../../../../common/http_api/inventory_meta_api'; import { findToolbar } from '../../../../common/inventory_models/toolbars'; -import { - InfraNodeType, - InfraSnapshotMetricInput, - InfraSnapshotGroupbyInput, -} from '../../../graphql/types'; import { ToolbarWrapper } from './toolbar_wrapper'; import { waffleOptionsSelectors } from '../../../store'; @@ -22,11 +18,12 @@ import { WithWaffleViewState } from '../../../containers/waffle/with_waffle_view import { SavedViewsToolbarControls } from '../../saved_views/toolbar_control'; import { inventoryViewSavedObjectType } from '../../../../common/saved_objects/inventory_view'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; export interface ToolbarProps { createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => IIndexPattern; - changeMetric: (payload: InfraSnapshotMetricInput) => Action; - changeGroupBy: (payload: InfraSnapshotGroupbyInput[]) => Action; + changeMetric: (payload: SnapshotMetricInput) => Action; + changeGroupBy: (payload: SnapshotGroupBy) => Action; changeCustomOptions: (payload: InfraGroupByOptions[]) => Action; changeAccount: (id: string) => Action; changeRegion: (name: string) => Action; @@ -70,7 +67,7 @@ const wrapToolbarItems = ( }; interface Props { - nodeType: InfraNodeType; + nodeType: InventoryItemType; regions: string[]; accounts: InventoryCloudAccount[]; } diff --git a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx index 9a31544ce4cceb..231030362438fc 100644 --- a/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx +++ b/x-pack/legacy/plugins/infra/public/components/inventory/toolbars/toolbar_wrapper.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; import { WithSource } from '../../../containers/with_source'; import { WithWaffleOptions } from '../../../containers/waffle/with_waffle_options'; import { Toolbar } from '../../eui/toolbar'; import { ToolbarProps } from './toolbar'; import { fieldToName } from '../../waffle/lib/field_to_display_name'; -import { InfraSnapshotMetricType } from '../../../graphql/types'; interface Props { children: (props: Omit) => React.ReactElement; @@ -144,123 +144,125 @@ export const toGroupByOpt = (field: string) => ({ field, }); -export const toMetricOpt = (metric: InfraSnapshotMetricType) => { +export const toMetricOpt = ( + metric: SnapshotMetricType +): { text: string; value: SnapshotMetricType } => { switch (metric) { - case InfraSnapshotMetricType.cpu: + case 'cpu': return { text: ToolbarTranslations.CPUUsage, - value: InfraSnapshotMetricType.cpu, + value: 'cpu', }; - case InfraSnapshotMetricType.memory: + case 'memory': return { text: ToolbarTranslations.MemoryUsage, - value: InfraSnapshotMetricType.memory, + value: 'memory', }; - case InfraSnapshotMetricType.rx: + case 'rx': return { text: ToolbarTranslations.InboundTraffic, - value: InfraSnapshotMetricType.rx, + value: 'rx', }; - case InfraSnapshotMetricType.tx: + case 'tx': return { text: ToolbarTranslations.OutboundTraffic, - value: InfraSnapshotMetricType.tx, + value: 'tx', }; - case InfraSnapshotMetricType.logRate: + case 'logRate': return { text: ToolbarTranslations.LogRate, - value: InfraSnapshotMetricType.logRate, + value: 'logRate', }; - case InfraSnapshotMetricType.load: + case 'load': return { text: ToolbarTranslations.Load, - value: InfraSnapshotMetricType.load, + value: 'load', }; - case InfraSnapshotMetricType.count: + case 'count': return { text: ToolbarTranslations.Count, - value: InfraSnapshotMetricType.count, + value: 'count', }; - case InfraSnapshotMetricType.diskIOReadBytes: + case 'diskIOReadBytes': return { text: ToolbarTranslations.DiskIOReadBytes, - value: InfraSnapshotMetricType.diskIOReadBytes, + value: 'diskIOReadBytes', }; - case InfraSnapshotMetricType.diskIOWriteBytes: + case 'diskIOWriteBytes': return { text: ToolbarTranslations.DiskIOWriteBytes, - value: InfraSnapshotMetricType.diskIOWriteBytes, + value: 'diskIOWriteBytes', }; - case InfraSnapshotMetricType.s3BucketSize: + case 's3BucketSize': return { text: ToolbarTranslations.s3BucketSize, - value: InfraSnapshotMetricType.s3BucketSize, + value: 's3BucketSize', }; - case InfraSnapshotMetricType.s3TotalRequests: + case 's3TotalRequests': return { text: ToolbarTranslations.s3TotalRequests, - value: InfraSnapshotMetricType.s3TotalRequests, + value: 's3TotalRequests', }; - case InfraSnapshotMetricType.s3NumberOfObjects: + case 's3NumberOfObjects': return { text: ToolbarTranslations.s3NumberOfObjects, - value: InfraSnapshotMetricType.s3NumberOfObjects, + value: 's3NumberOfObjects', }; - case InfraSnapshotMetricType.s3DownloadBytes: + case 's3DownloadBytes': return { text: ToolbarTranslations.s3DownloadBytes, - value: InfraSnapshotMetricType.s3DownloadBytes, + value: 's3DownloadBytes', }; - case InfraSnapshotMetricType.s3UploadBytes: + case 's3UploadBytes': return { text: ToolbarTranslations.s3UploadBytes, - value: InfraSnapshotMetricType.s3UploadBytes, + value: 's3UploadBytes', }; - case InfraSnapshotMetricType.rdsConnections: + case 'rdsConnections': return { text: ToolbarTranslations.rdsConnections, - value: InfraSnapshotMetricType.rdsConnections, + value: 'rdsConnections', }; - case InfraSnapshotMetricType.rdsQueriesExecuted: + case 'rdsQueriesExecuted': return { text: ToolbarTranslations.rdsQueriesExecuted, - value: InfraSnapshotMetricType.rdsQueriesExecuted, + value: 'rdsQueriesExecuted', }; - case InfraSnapshotMetricType.rdsActiveTransactions: + case 'rdsActiveTransactions': return { text: ToolbarTranslations.rdsActiveTransactions, - value: InfraSnapshotMetricType.rdsActiveTransactions, + value: 'rdsActiveTransactions', }; - case InfraSnapshotMetricType.rdsLatency: + case 'rdsLatency': return { text: ToolbarTranslations.rdsLatency, - value: InfraSnapshotMetricType.rdsLatency, + value: 'rdsLatency', }; - case InfraSnapshotMetricType.sqsMessagesVisible: + case 'sqsMessagesVisible': return { text: ToolbarTranslations.sqsMessagesVisible, - value: InfraSnapshotMetricType.sqsMessagesVisible, + value: 'sqsMessagesVisible', }; - case InfraSnapshotMetricType.sqsMessagesDelayed: + case 'sqsMessagesDelayed': return { text: ToolbarTranslations.sqsMessagesDelayed, - value: InfraSnapshotMetricType.sqsMessagesDelayed, + value: 'sqsMessagesDelayed', }; - case InfraSnapshotMetricType.sqsMessagesSent: + case 'sqsMessagesSent': return { text: ToolbarTranslations.sqsMessagesSent, - value: InfraSnapshotMetricType.sqsMessagesSent, + value: 'sqsMessagesSent', }; - case InfraSnapshotMetricType.sqsMessagesEmpty: + case 'sqsMessagesEmpty': return { text: ToolbarTranslations.sqsMessagesEmpty, - value: InfraSnapshotMetricType.sqsMessagesEmpty, + value: 'sqsMessagesEmpty', }; - case InfraSnapshotMetricType.sqsOldestMessage: + case 'sqsOldestMessage': return { text: ToolbarTranslations.sqsOldestMessage, - value: InfraSnapshotMetricType.sqsOldestMessage, + value: 'sqsOldestMessage', }; } }; diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx index 6c044baa56d7ce..4dd46e9ef233ad 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { MetricsExplorerChartContextMenu, createNodeDetailLink } from './chart_context_menu'; import { mount } from 'enzyme'; import { options, source, timeRange, chartOptions } from '../../utils/fixtures/metrics_explorer'; -import { InfraNodeType } from '../../graphql/types'; import DateMath from '@elastic/datemath'; import { ReactWrapper } from 'enzyme'; import { Capabilities } from 'src/core/public'; @@ -163,12 +162,7 @@ describe('MetricsExplorerChartContextMenu', () => { const toDateStrig = '2019-01-01T12:00:00Z'; const to = DateMath.parse(toDateStrig, { roundUp: true })!; const from = DateMath.parse(fromDateStrig)!; - const link = createNodeDetailLink( - InfraNodeType.host, - 'example-01', - fromDateStrig, - toDateStrig - ); + const link = createNodeDetailLink('host', 'example-01', fromDateStrig, toDateStrig); expect(link).toBe( `#/link-to/host-detail/example-01?to=${to.valueOf()}&from=${from.valueOf()}` ); diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx index 597386fc24ee84..298f7dd8f8d175 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart_context_menu.tsx @@ -21,9 +21,9 @@ import { MetricsExplorerChartOptions, } from '../../containers/metrics_explorer/use_metrics_explorer_options'; import { createTSVBLink } from './helpers/create_tsvb_link'; -import { InfraNodeType } from '../../graphql/types'; import { getNodeDetailUrl } from '../../pages/link_to/redirect_to_node_detail'; import { SourceConfiguration } from '../../utils/source_configuration'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { options: MetricsExplorerOptions; @@ -35,15 +35,18 @@ interface Props { chartOptions: MetricsExplorerChartOptions; } -const fieldToNodeType = (source: SourceConfiguration, field: string): InfraNodeType | undefined => { +const fieldToNodeType = ( + source: SourceConfiguration, + field: string +): InventoryItemType | undefined => { if (source.fields.host === field) { - return InfraNodeType.host; + return 'host'; } if (source.fields.pod === field) { - return InfraNodeType.pod; + return 'pod'; } if (source.fields.container === field) { - return InfraNodeType.container; + return 'container'; } }; @@ -54,7 +57,7 @@ const dateMathExpressionToEpoch = (dateMathExpression: string, roundUp = false): }; export const createNodeDetailLink = ( - nodeType: InfraNodeType, + nodeType: InventoryItemType, nodeId: string, from: string, to: string diff --git a/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx b/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx index 21db0c73f612ae..8e8015ce6a82e7 100644 --- a/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx +++ b/x-pack/legacy/plugins/infra/public/components/nodes_overview/index.tsx @@ -11,7 +11,6 @@ import { get, max, min } from 'lodash'; import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraSnapshotMetricType, InfraSnapshotNode, InfraNodeType } from '../../graphql/types'; import { InfraFormatterType, InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib'; import { KueryFilterQuery } from '../../store/local/waffle_filter'; import { createFormatter } from '../../utils/formatters'; @@ -22,10 +21,11 @@ import { ViewSwitcher } from '../waffle/view_switcher'; import { TableView } from './table'; import { SnapshotNode } from '../../../common/http_api/snapshot_api'; import { convertIntervalToString } from '../../utils/convert_interval_to_string'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { options: InfraWaffleMapOptions; - nodeType: InfraNodeType; + nodeType: InventoryItemType; nodes: SnapshotNode[]; loading: boolean; reload: () => void; @@ -49,56 +49,56 @@ interface MetricFormatters { } const METRIC_FORMATTERS: MetricFormatters = { - [InfraSnapshotMetricType.count]: { formatter: InfraFormatterType.number, template: '{{value}}' }, - [InfraSnapshotMetricType.cpu]: { + ['count']: { formatter: InfraFormatterType.number, template: '{{value}}' }, + ['cpu']: { formatter: InfraFormatterType.percent, template: '{{value}}', }, - [InfraSnapshotMetricType.memory]: { + ['memory']: { formatter: InfraFormatterType.percent, template: '{{value}}', }, - [InfraSnapshotMetricType.rx]: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, - [InfraSnapshotMetricType.tx]: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, - [InfraSnapshotMetricType.logRate]: { + ['rx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, + ['tx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, + ['logRate']: { formatter: InfraFormatterType.abbreviatedNumber, template: '{{value}}/s', }, - [InfraSnapshotMetricType.diskIOReadBytes]: { + ['diskIOReadBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}/s', }, - [InfraSnapshotMetricType.diskIOWriteBytes]: { + ['diskIOWriteBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}/s', }, - [InfraSnapshotMetricType.s3BucketSize]: { + ['s3BucketSize']: { formatter: InfraFormatterType.bytes, template: '{{value}}', }, - [InfraSnapshotMetricType.s3TotalRequests]: { + ['s3TotalRequests']: { formatter: InfraFormatterType.abbreviatedNumber, template: '{{value}}', }, - [InfraSnapshotMetricType.s3NumberOfObjects]: { + ['s3NumberOfObjects']: { formatter: InfraFormatterType.abbreviatedNumber, template: '{{value}}', }, - [InfraSnapshotMetricType.s3UploadBytes]: { + ['s3UploadBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}', }, - [InfraSnapshotMetricType.s3DownloadBytes]: { + ['s3DownloadBytes']: { formatter: InfraFormatterType.bytes, template: '{{value}}', }, - [InfraSnapshotMetricType.sqsOldestMessage]: { + ['sqsOldestMessage']: { formatter: InfraFormatterType.number, template: '{{value}} seconds', }, }; -const calculateBoundsFromNodes = (nodes: InfraSnapshotNode[]): InfraWaffleMapBounds => { +const calculateBoundsFromNodes = (nodes: SnapshotNode[]): InfraWaffleMapBounds => { const maxValues = nodes.map(node => node.metric.max); const minValues = nodes.map(node => node.metric.value); // if there is only one value then we need to set the bottom range to zero for min @@ -211,11 +211,7 @@ export const NodesOverview = class extends React.Component { // TODO: Change this to a real implimentation using the tickFormatter from the prototype as an example. private formatter = (val: string | number) => { const { metric } = this.props.options; - const metricFormatter = get( - METRIC_FORMATTERS, - metric.type, - METRIC_FORMATTERS[InfraSnapshotMetricType.count] - ); + const metricFormatter = get(METRIC_FORMATTERS, metric.type, METRIC_FORMATTERS.count); if (val == null) { return ''; } diff --git a/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx b/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx index 8996b170f01d21..dc0de6f6e9c69a 100644 --- a/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx +++ b/x-pack/legacy/plugins/infra/public/components/nodes_overview/table.tsx @@ -10,14 +10,15 @@ import { i18n } from '@kbn/i18n'; import { last } from 'lodash'; import React from 'react'; import { createWaffleMapNode } from '../../containers/waffle/nodes_to_wafflemap'; -import { InfraSnapshotNode, InfraSnapshotNodePath, InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { fieldToName } from '../waffle/lib/field_to_display_name'; import { NodeContextMenu } from '../waffle/node_context_menu'; +import { InventoryItemType } from '../../../common/inventory_models/types'; +import { SnapshotNode, SnapshotNodePath } from '../../../common/http_api/snapshot_api'; interface Props { - nodes: InfraSnapshotNode[]; - nodeType: InfraNodeType; + nodes: SnapshotNode[]; + nodeType: InventoryItemType; options: InfraWaffleMapOptions; formatter: (subject: string | number) => string; currentTime: number; @@ -30,7 +31,7 @@ const initialState = { type State = Readonly; -const getGroupPaths = (path: InfraSnapshotNodePath[]) => { +const getGroupPaths = (path: SnapshotNodePath[]) => { switch (path.length) { case 3: return path.slice(0, 2); diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx index e65753ef24e9e3..e80b1489728cc4 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/fields_configuration_panel.tsx @@ -12,7 +12,10 @@ import { EuiFormRow, EuiSpacer, EuiTitle, + EuiCallOut, + EuiLink, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; @@ -38,259 +41,302 @@ export const FieldsConfigurationPanel = ({ tiebreakerFieldProps, timestampFieldProps, displaySettings, -}: FieldsConfigurationPanelProps) => ( - - -

- -

-
- - +}: FieldsConfigurationPanelProps) => { + const isHostValueDefault = hostFieldProps.value === 'host.name'; + const isContainerValueDefault = containerFieldProps.value === 'container.id'; + const isPodValueDefault = podFieldProps.value === 'kubernetes.pod.uid'; + const isTimestampValueDefault = timestampFieldProps.value === '@timestamp'; + const isTiebreakerValueDefault = tiebreakerFieldProps.value === '_doc'; + return ( + + +

-

- } - description={ - - } - > - +
+ + +

@timestamp, + documentationLink: ( + + + + ), + ecsLink: ( + + ECS + + ), }} /> +

+
+ + + + } - isInvalid={timestampFieldProps.isInvalid} - label={ + description={ } > - - - - {displaySettings === 'logs' && ( - <> - - - + helpText={ + @timestamp, + }} + /> } - description={ + isInvalid={timestampFieldProps.isInvalid} + label={ } > - _doc, - }} - /> + disabled={isLoading || isTimestampValueDefault} + readOnly={readOnly} + isLoading={isLoading} + {...timestampFieldProps} + /> + + + {displaySettings === 'logs' && ( + <> + + + } - isInvalid={tiebreakerFieldProps.isInvalid} - label={ + description={ } > - - - - - )} - {displaySettings === 'metrics' && ( - <> - - - - } - description={ - - } - > - container.id, - }} + helpText={ + _doc, + }} + /> + } + isInvalid={tiebreakerFieldProps.isInvalid} + label={ + + } + > + + + + + )} + {displaySettings === 'metrics' && ( + <> + + + } - isInvalid={containerFieldProps.isInvalid} - label={ + description={ } > - - - - - - - } - description={ - - } - > - host.name, - }} + helpText={ + container.id, + }} + /> + } + isInvalid={containerFieldProps.isInvalid} + label={ + + } + > + + + + + + } - isInvalid={hostFieldProps.isInvalid} - label={ + description={ } > - - - - - - - } - description={ - - } - > - kubernetes.pod.uid, - }} + helpText={ + host.name, + }} + /> + } + isInvalid={hostFieldProps.isInvalid} + label={ + + } + > + + + + + + } - isInvalid={podFieldProps.isInvalid} - label={ + description={ } > - - - - - )} -
-); + helpText={ + kubernetes.pod.uid, + }} + /> + } + isInvalid={podFieldProps.isInvalid} + label={ + + } + > + + +
+ + )} +
+ ); +}; diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx index 7a229fbbe02ec6..60a117e3ed6171 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_groups.tsx @@ -7,7 +7,6 @@ import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapGroupOfGroups, @@ -15,6 +14,7 @@ import { } from '../../lib/lib'; import { GroupName } from './group_name'; import { GroupOfNodes } from './group_of_nodes'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { onDrilldown: (filter: string) => void; @@ -22,7 +22,7 @@ interface Props { group: InfraWaffleMapGroupOfGroups; formatter: (val: number) => string; bounds: InfraWaffleMapBounds; - nodeType: InfraNodeType; + nodeType: InventoryItemType; currentTime: number; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx index c40c68cbdbf280..b47b8f6a1bf39a 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/group_of_nodes.tsx @@ -7,7 +7,6 @@ import React from 'react'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapGroupOfNodes, @@ -15,6 +14,7 @@ import { } from '../../lib/lib'; import { GroupName } from './group_name'; import { Node } from './node'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { onDrilldown: (filter: string) => void; @@ -23,7 +23,7 @@ interface Props { formatter: (val: number) => string; isChild: boolean; bounds: InfraWaffleMapBounds; - nodeType: InfraNodeType; + nodeType: InventoryItemType; currentTime: number; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts index 6fdf8be15bc9e5..1a285ceefec0e1 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts +++ b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.test.ts @@ -10,8 +10,7 @@ import { InfraWaffleMapLegendMode, InfraFormatterType, } from '../../../lib/lib'; -import { InfraNodeType } from '../../../../common/graphql/types'; -import { InfraSnapshotMetricType } from '../../../graphql/types'; +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; const options: InfraWaffleMapOptions = { fields: { @@ -24,7 +23,7 @@ const options: InfraWaffleMapOptions = { }, formatter: InfraFormatterType.percent, formatTemplate: '{{value}}', - metric: { type: InfraSnapshotMetricType.cpu }, + metric: { type: 'cpu' }, groupBy: [], legend: { type: InfraWaffleMapLegendMode.gradient, @@ -41,13 +40,13 @@ describe('createUptimeLink()', () => { ip: '10.0.1.2', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.host, node)).toBe( + expect(createUptimeLink(options, 'host', node)).toBe( '../app/uptime#/?search=host.ip:"10.0.1.2"' ); }); @@ -59,13 +58,13 @@ describe('createUptimeLink()', () => { name: 'host-01', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.host, node)).toBe( + expect(createUptimeLink(options, 'host', node)).toBe( '../app/uptime#/?search=host.name:"host-01"' ); }); @@ -77,13 +76,13 @@ describe('createUptimeLink()', () => { name: 'pod-01', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.pod, node)).toBe( + expect(createUptimeLink(options, 'pod', node)).toBe( '../app/uptime#/?search=kubernetes.pod.uid:"29193-pod-02939"' ); }); @@ -95,13 +94,13 @@ describe('createUptimeLink()', () => { name: 'docker-01', path: [], metric: { - name: InfraSnapshotMetricType.cpu, + name: 'cpu' as SnapshotMetricType, value: 0.5, max: 0.8, avg: 0.6, }, }; - expect(createUptimeLink(options, InfraNodeType.container, node)).toBe( + expect(createUptimeLink(options, 'container', node)).toBe( '../app/uptime#/?search=container.id:"docker-1234"' ); }); diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts index 7fc0d3cfeee416..05b4b6d514cd06 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts +++ b/x-pack/legacy/plugins/infra/public/components/waffle/lib/create_uptime_link.ts @@ -5,17 +5,17 @@ */ import { get } from 'lodash'; -import { InfraNodeType } from '../../../graphql/types'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../lib/lib'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; const BASE_URL = '../app/uptime#/?search='; export const createUptimeLink = ( options: InfraWaffleMapOptions, - nodeType: InfraNodeType, + nodeType: InventoryItemType, node: InfraWaffleMapNode ) => { - if (nodeType === InfraNodeType.host && node.ip) { + if (nodeType === 'host' && node.ip) { return `${BASE_URL}host.ip:"${node.ip}"`; } const field = get(options, ['fields', nodeType], ''); diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx index 6c0209a60f1cd5..91fb84840f37f5 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/map.tsx @@ -11,17 +11,18 @@ import { isWaffleMapGroupWithGroups, isWaffleMapGroupWithNodes, } from '../../containers/waffle/type_guards'; -import { InfraSnapshotNode, InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapOptions } from '../../lib/lib'; import { AutoSizer } from '../auto_sizer'; import { GroupOfGroups } from './group_of_groups'; import { GroupOfNodes } from './group_of_nodes'; import { Legend } from './legend'; import { applyWaffleMapLayout } from './lib/apply_wafflemap_layout'; +import { SnapshotNode } from '../../../common/http_api/snapshot_api'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { - nodes: InfraSnapshotNode[]; - nodeType: InfraNodeType; + nodes: SnapshotNode[]; + nodeType: InventoryItemType; options: InfraWaffleMapOptions; formatter: (subject: string | number) => string; currentTime: number; diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx index f0770064c3cf99..72e2a4c8f35d7c 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx @@ -11,10 +11,10 @@ import { i18n } from '@kbn/i18n'; import { ConditionalToolTip } from './conditional_tooltip'; import euiStyled from '../../../../../common/eui_styled_components'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { colorFromValue } from './lib/color_from_value'; import { NodeContextMenu } from './node_context_menu'; +import { InventoryItemType } from '../../../common/inventory_models/types'; const initialState = { isPopoverOpen: false, @@ -28,7 +28,7 @@ interface Props { node: InfraWaffleMapNode; formatter: (val: number) => string; bounds: InfraWaffleMapBounds; - nodeType: InfraNodeType; + nodeType: InventoryItemType; currentTime: number; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx index cb894f37c1fce4..5a90efcc51a574 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/node_context_menu.tsx @@ -13,19 +13,19 @@ import { import { i18n } from '@kbn/i18n'; import React from 'react'; -import { InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../pages/link_to'; import { createUptimeLink } from './lib/create_uptime_link'; import { findInventoryModel } from '../../../common/inventory_models'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { InventoryItemType } from '../../../common/inventory_models/types'; interface Props { options: InfraWaffleMapOptions; currentTime: number; children: any; node: InfraWaffleMapNode; - nodeType: InfraNodeType; + nodeType: InventoryItemType; isPopoverOpen: boolean; closePopover: () => void; popoverPosition: EuiPopoverProps['anchorPosition']; @@ -47,7 +47,7 @@ export const NodeContextMenu = ({ // We need to have some exceptions until 7.0 & ECS is finalized. Reference // #26620 for the details for these fields. // TODO: This is tech debt, remove it after 7.0 & ECS migration. - const apmField = nodeType === InfraNodeType.host ? 'host.hostname' : inventoryModel.fields.id; + const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id; const nodeLogsMenuItem = { name: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', { @@ -95,8 +95,7 @@ export const NodeContextMenu = ({ const showAPMTraceLink = inventoryModel.crosslinkSupport.apm && uiCapabilities?.apm && uiCapabilities?.apm.show; const showUptimeLink = - inventoryModel.crosslinkSupport.uptime && - ([InfraNodeType.pod, InfraNodeType.container].includes(nodeType) || node.ip); + inventoryModel.crosslinkSupport.uptime && (['pod', 'container'].includes(nodeType) || node.ip); const items = [ ...(showLogsLink ? [nodeLogsMenuItem] : []), diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx index 0a9df1f666f3d5..003eeb96cc41c4 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx @@ -17,16 +17,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { IFieldType } from 'src/plugins/data/public'; -import { InfraNodeType, InfraSnapshotGroupbyInput } from '../../graphql/types'; import { InfraGroupByOptions } from '../../lib/lib'; import { CustomFieldPanel } from './custom_field_panel'; import euiStyled from '../../../../../common/eui_styled_components'; +import { InventoryItemType } from '../../../common/inventory_models/types'; +import { SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; interface Props { options: Array<{ text: string; field: string; toolTipContent?: string }>; - nodeType: InfraNodeType; - groupBy: InfraSnapshotGroupbyInput[]; - onChange: (groupBy: InfraSnapshotGroupbyInput[]) => void; + nodeType: InventoryItemType; + groupBy: SnapshotGroupBy; + onChange: (groupBy: SnapshotGroupBy) => void; onChangeCustomOptions: (options: InfraGroupByOptions[]) => void; fields: IFieldType[]; customOptions: InfraGroupByOptions[]; diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx index bdd08ab6b366f7..dec988593b0cef 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_inventory_switcher.tsx @@ -14,20 +14,17 @@ import { import React, { useCallback, useState, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - InfraSnapshotMetricInput, - InfraSnapshotMetricType, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../graphql/types'; import { findInventoryModel } from '../../../common/inventory_models'; import { InventoryItemType } from '../../../common/inventory_models/types'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; interface WaffleInventorySwitcherProps { - nodeType: InfraNodeType; - changeNodeType: (nodeType: InfraNodeType) => void; - changeGroupBy: (groupBy: InfraSnapshotGroupbyInput[]) => void; - changeMetric: (metric: InfraSnapshotMetricInput) => void; + nodeType: InventoryItemType; + changeNodeType: (nodeType: InventoryItemType) => void; + changeGroupBy: (groupBy: SnapshotGroupBy) => void; + changeMetric: (metric: SnapshotMetricInput) => void; + changeAccount: (id: string) => void; + changeRegion: (name: string) => void; } const getDisplayNameForType = (type: InventoryItemType) => { @@ -39,30 +36,34 @@ export const WaffleInventorySwitcher: React.FC = ( changeNodeType, changeGroupBy, changeMetric, + changeAccount, + changeRegion, nodeType, }) => { const [isOpen, setIsOpen] = useState(false); const closePopover = useCallback(() => setIsOpen(false), []); const openPopover = useCallback(() => setIsOpen(true), []); const goToNodeType = useCallback( - (targetNodeType: InfraNodeType) => { + (targetNodeType: InventoryItemType) => { closePopover(); changeNodeType(targetNodeType); changeGroupBy([]); + changeAccount(''); + changeRegion(''); const inventoryModel = findInventoryModel(targetNodeType); changeMetric({ - type: inventoryModel.metrics.defaultSnapshot as InfraSnapshotMetricType, + type: inventoryModel.metrics.defaultSnapshot, }); }, - [closePopover, changeNodeType, changeGroupBy, changeMetric] + [closePopover, changeNodeType, changeGroupBy, changeMetric, changeAccount, changeRegion] ); - const goToHost = useCallback(() => goToNodeType('host' as InfraNodeType), [goToNodeType]); - const goToK8 = useCallback(() => goToNodeType('pod' as InfraNodeType), [goToNodeType]); - const goToDocker = useCallback(() => goToNodeType('container' as InfraNodeType), [goToNodeType]); - const goToAwsEC2 = useCallback(() => goToNodeType('awsEC2' as InfraNodeType), [goToNodeType]); - const goToAwsS3 = useCallback(() => goToNodeType('awsS3' as InfraNodeType), [goToNodeType]); - const goToAwsRDS = useCallback(() => goToNodeType('awsRDS' as InfraNodeType), [goToNodeType]); - const goToAwsSQS = useCallback(() => goToNodeType('awsSQS' as InfraNodeType), [goToNodeType]); + const goToHost = useCallback(() => goToNodeType('host'), [goToNodeType]); + const goToK8 = useCallback(() => goToNodeType('pod'), [goToNodeType]); + const goToDocker = useCallback(() => goToNodeType('container'), [goToNodeType]); + const goToAwsEC2 = useCallback(() => goToNodeType('awsEC2'), [goToNodeType]); + const goToAwsS3 = useCallback(() => goToNodeType('awsS3'), [goToNodeType]); + const goToAwsRDS = useCallback(() => goToNodeType('awsRDS'), [goToNodeType]); + const goToAwsSQS = useCallback(() => goToNodeType('awsSQS'), [goToNodeType]); const panels = useMemo( () => [ diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx index d5ae6fcf7f7a2b..f9e48730eaaf24 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_metric_controls.tsx @@ -14,13 +14,13 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; - -import { InfraSnapshotMetricInput, InfraSnapshotMetricType } from '../../graphql/types'; +import { SnapshotMetricInput } from '../../../common/http_api/snapshot_api'; +import { SnapshotMetricType } from '../../../common/inventory_models/types'; interface Props { - options: Array<{ text: string; value: InfraSnapshotMetricType }>; - metric: InfraSnapshotMetricInput; - onChange: (metric: InfraSnapshotMetricInput) => void; + options: Array<{ text: string; value: SnapshotMetricType }>; + metric: SnapshotMetricInput; + onChange: (metric: SnapshotMetricInput) => void; } const initialState = { @@ -90,7 +90,7 @@ export const WaffleMetricControls = class extends React.PureComponent ({ isPopoverOpen: !state.isPopoverOpen })); }; - private handleClick = (value: InfraSnapshotMetricType) => () => { + private handleClick = (value: SnapshotMetricType) => () => { this.props.onChange({ type: value }); this.handleClose(); }; diff --git a/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts b/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts index c7a7b4d61bd124..0ed1f3e35449b4 100644 --- a/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts +++ b/x-pack/legacy/plugins/infra/public/containers/inventory_metadata/use_inventory_meta.ts @@ -13,9 +13,9 @@ import { InventoryMetaResponseRT, InventoryMetaResponse, } from '../../../common/http_api/inventory_meta_api'; -import { InfraNodeType } from '../../graphql/types'; +import { InventoryItemType } from '../../../common/inventory_models/types'; -export function useInventoryMeta(sourceId: string, nodeType: InfraNodeType) { +export function useInventoryMeta(sourceId: string, nodeType: InventoryItemType) { const decodeResponse = (response: any) => { return pipe( InventoryMetaResponseRT.decode(response), diff --git a/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts b/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts index 83e176aea1039a..52c522ce8efd42 100644 --- a/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts +++ b/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts @@ -8,16 +8,15 @@ import { useEffect } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { InfraNodeType } from '../../graphql/types'; import { InfraMetadata, InfraMetadataRT } from '../../../common/http_api/metadata_api'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; -import { InventoryMetric } from '../../../common/inventory_models/types'; +import { InventoryMetric, InventoryItemType } from '../../../common/inventory_models/types'; import { getFilteredMetrics } from './lib/get_filtered_metrics'; export function useMetadata( nodeId: string, - nodeType: InfraNodeType, + nodeType: InventoryItemType, requiredMetrics: InventoryMetric[], sourceId: string ) { diff --git a/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts b/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts index e86e56a5911998..189c48ba1a70c4 100644 --- a/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts +++ b/x-pack/legacy/plugins/infra/public/containers/node_details/use_node_details.ts @@ -6,19 +6,19 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { InfraNodeType, InfraTimerangeInput } from '../../graphql/types'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { NodeDetailsMetricDataResponseRT, NodeDetailsMetricDataResponse, } from '../../../common/http_api/node_details_api'; -import { InventoryMetric } from '../../../common/inventory_models/types'; +import { InventoryMetric, InventoryItemType } from '../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; export function useNodeDetails( metrics: InventoryMetric[], nodeId: string, - nodeType: InfraNodeType, + nodeType: InventoryItemType, sourceId: string, timerange: InfraTimerangeInput, cloudId: string diff --git a/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts b/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts index 18595d47886300..d2511ba7e669e6 100644 --- a/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts +++ b/x-pack/legacy/plugins/infra/public/containers/waffle/nodes_to_wafflemap.ts @@ -6,8 +6,6 @@ import { i18n } from '@kbn/i18n'; import { first, last } from 'lodash'; - -import { InfraSnapshotNode, InfraSnapshotNodePath } from '../../graphql/types'; import { InfraWaffleMapGroup, InfraWaffleMapGroupOfGroups, @@ -15,14 +13,15 @@ import { InfraWaffleMapNode, } from '../../lib/lib'; import { isWaffleMapGroupWithGroups, isWaffleMapGroupWithNodes } from './type_guards'; +import { SnapshotNodePath, SnapshotNode } from '../../../common/http_api/snapshot_api'; -export function createId(path: InfraSnapshotNodePath[]) { +export function createId(path: SnapshotNodePath[]) { return path.map(p => p.value).join('/'); } function findOrCreateGroupWithNodes( groups: InfraWaffleMapGroup[], - path: InfraSnapshotNodePath[] + path: SnapshotNodePath[] ): InfraWaffleMapGroupOfNodes { const id = path.length === 0 ? '__all__' : createId(path); /** @@ -62,7 +61,7 @@ function findOrCreateGroupWithNodes( function findOrCreateGroupWithGroups( groups: InfraWaffleMapGroup[], - path: InfraSnapshotNodePath[] + path: SnapshotNodePath[] ): InfraWaffleMapGroupOfGroups { const id = path.length === 0 ? '__all__' : createId(path); const lastPath = last(path); @@ -85,7 +84,7 @@ function findOrCreateGroupWithGroups( }; } -export function createWaffleMapNode(node: InfraSnapshotNode): InfraWaffleMapNode { +export function createWaffleMapNode(node: SnapshotNode): InfraWaffleMapNode { const nodePathItem = last(node.path); if (!nodePathItem) { throw new Error('There must be at least one node path item'); @@ -106,8 +105,8 @@ function withoutGroup(group: InfraWaffleMapGroup) { }; } -export function nodesToWaffleMap(nodes: InfraSnapshotNode[]): InfraWaffleMapGroup[] { - return nodes.reduce((groups: InfraWaffleMapGroup[], node: InfraSnapshotNode) => { +export function nodesToWaffleMap(nodes: SnapshotNode[]): InfraWaffleMapGroup[] { + return nodes.reduce((groups: InfraWaffleMapGroup[], node: SnapshotNode) => { const waffleNode = createWaffleMapNode(node); if (node.path.length === 2) { const parentGroup = findOrCreateGroupWithNodes( diff --git a/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts b/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts index d318d812353092..31f02f46caedac 100644 --- a/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts +++ b/x-pack/legacy/plugins/infra/public/containers/waffle/use_snaphot.ts @@ -8,23 +8,20 @@ import { useEffect } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { - InfraNodeType, - InfraSnapshotMetricInput, - InfraSnapshotGroupbyInput, -} from '../../graphql/types'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { useHTTPRequest } from '../../hooks/use_http_request'; import { SnapshotNodeResponseRT, SnapshotNodeResponse, + SnapshotGroupBy, } from '../../../common/http_api/snapshot_api'; +import { InventoryItemType, SnapshotMetricType } from '../../../common/inventory_models/types'; export function useSnapshot( filterQuery: string | null | undefined, - metric: InfraSnapshotMetricInput, - groupBy: InfraSnapshotGroupbyInput[], - nodeType: InfraNodeType, + metric: { type: SnapshotMetricType }, + groupBy: SnapshotGroupBy, + nodeType: InventoryItemType, sourceId: string, currentTime: number, accountId: string, diff --git a/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx b/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx index 6d10a753f9ed0f..45222b32aa51ff 100644 --- a/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/waffle/with_waffle_options.tsx @@ -9,17 +9,17 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { isBoolean, isNumber } from 'lodash'; -import { - InfraSnapshotMetricInput, - InfraSnapshotMetricType, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../graphql/types'; import { InfraGroupByOptions } from '../../lib/lib'; import { State, waffleOptionsActions, waffleOptionsSelectors } from '../../store'; import { asChildFunctionRenderer } from '../../utils/typed_react'; import { bindPlainActionCreators } from '../../utils/typed_redux'; import { UrlStateContainer } from '../../utils/url_state'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../common/http_api/snapshot_api'; +import { + SnapshotMetricTypeRT, + InventoryItemType, + ItemTypeRT, +} from '../../../common/inventory_models/types'; const selectOptionsUrlState = createSelector( waffleOptionsSelectors.selectMetric, @@ -194,13 +194,13 @@ const mapToUrlState = (value: any): WaffleOptionsUrlState | undefined => } : undefined; -const isInfraNodeType = (value: any): value is InfraNodeType => value in InfraNodeType; +const isInfraNodeType = (value: any): value is InventoryItemType => value in ItemTypeRT; -const isInfraSnapshotMetricInput = (subject: any): subject is InfraSnapshotMetricInput => { - return subject != null && subject.type in InfraSnapshotMetricType; +const isInfraSnapshotMetricInput = (subject: any): subject is SnapshotMetricInput => { + return subject != null && subject.type in SnapshotMetricTypeRT; }; -const isInfraSnapshotGroupbyInput = (subject: any): subject is InfraSnapshotGroupbyInput => { +const isInfraSnapshotGroupbyInput = (subject: any): subject is SnapshotGroupBy => { return subject != null && subject.type != null; }; diff --git a/x-pack/legacy/plugins/infra/public/containers/with_options.tsx b/x-pack/legacy/plugins/infra/public/containers/with_options.tsx index 349d7fda74f1fe..4294697fd4103e 100644 --- a/x-pack/legacy/plugins/infra/public/containers/with_options.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/with_options.tsx @@ -7,7 +7,6 @@ import moment from 'moment'; import React from 'react'; -import { InfraSnapshotMetricType } from '../graphql/types'; import { InfraFormatterType, InfraOptions, InfraWaffleMapLegendMode } from '../lib/lib'; import { RendererFunction } from '../utils/typed_react'; @@ -24,7 +23,7 @@ const initialState = { wafflemap: { formatter: InfraFormatterType.percent, formatTemplate: '{{value}}', - metric: { type: InfraSnapshotMetricType.cpu }, + metric: { type: 'cpu' }, groupBy: [], legend: { type: InfraWaffleMapLegendMode.gradient, diff --git a/x-pack/legacy/plugins/infra/public/lib/lib.ts b/x-pack/legacy/plugins/infra/public/lib/lib.ts index 4b402ce6beafe3..9f851e185018b6 100644 --- a/x-pack/legacy/plugins/infra/public/lib/lib.ts +++ b/x-pack/legacy/plugins/infra/public/lib/lib.ts @@ -10,14 +10,14 @@ import ApolloClient from 'apollo-client'; import { AxiosRequestConfig } from 'axios'; import React from 'react'; import { Observable } from 'rxjs'; +import { SourceQuery } from '../graphql/types'; import { - InfraSnapshotMetricInput, - InfraSnapshotNodeMetric, - InfraSnapshotNodePath, - InfraSnapshotGroupbyInput, + SnapshotMetricInput, + SnapshotGroupBy, InfraTimerangeInput, - SourceQuery, -} from '../graphql/types'; + SnapshotNodeMetric, + SnapshotNodePath, +} from '../../common/http_api/snapshot_api'; export interface InfraFrontendLibs { apolloClient: InfraApolloClient; @@ -102,8 +102,8 @@ export interface InfraWaffleMapNode { id: string; name: string; ip?: string | null; - path: InfraSnapshotNodePath[]; - metric: InfraSnapshotNodeMetric; + path: SnapshotNodePath[]; + metric: SnapshotNodeMetric; } export type InfraWaffleMapGroup = InfraWaffleMapGroupOfNodes | InfraWaffleMapGroupOfGroups; @@ -165,8 +165,8 @@ export interface InfraWaffleMapOptions { fields?: SourceQuery.Query['source']['configuration']['fields'] | null; formatter: InfraFormatterType; formatTemplate: string; - metric: InfraSnapshotMetricInput; - groupBy: InfraSnapshotGroupbyInput[]; + metric: SnapshotMetricInput; + groupBy: SnapshotGroupBy; legend: InfraWaffleMapLegend; } diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx index a828cd207aa5ba..a5780f44050e13 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx @@ -23,12 +23,21 @@ export const SnapshotToolbar = () => ( - {({ changeMetric, changeNodeType, changeGroupBy, nodeType }) => ( + {({ + changeMetric, + changeNodeType, + changeGroupBy, + changeAccount, + changeRegion, + nodeType, + }) => ( )} diff --git a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx index a2cebbb96a4f0d..ec1cc8ba45332e 100644 --- a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_detail.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; import { replaceMetricTimeInQueryString } from '../metrics/containers/with_metrics_time'; -import { InfraNodeType } from '../../graphql/types'; import { getFromFromLocation, getToFromLocation } from './query_params'; +import { InventoryItemType } from '../../../common/inventory_models/types'; type RedirectToNodeDetailProps = RouteComponentProps<{ nodeId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; }>; export const RedirectToNodeDetail = ({ @@ -36,7 +36,7 @@ export const getNodeDetailUrl = ({ to, from, }: { - nodeType: InfraNodeType; + nodeType: InventoryItemType; nodeId: string; to?: number; from?: number; diff --git a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx index 99b558cf1cafab..6f1fdd606dd736 100644 --- a/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/link_to/redirect_to_node_logs.tsx @@ -14,18 +14,22 @@ import { LoadingPage } from '../../components/loading_page'; import { replaceLogFilterInQueryString } from '../../containers/logs/log_filter'; import { replaceLogPositionInQueryString } from '../../containers/logs/log_position'; import { replaceSourceIdInQueryString } from '../../containers/source_id'; -import { InfraNodeType, SourceConfigurationFields } from '../../graphql/types'; +import { SourceConfigurationFields } from '../../graphql/types'; import { getFilterFromLocation, getTimeFromLocation } from './query_params'; import { useSource } from '../../containers/source/source'; import { findInventoryFields } from '../../../common/inventory_models'; +import { InventoryItemType } from '../../../common/inventory_models/types'; type RedirectToNodeLogsType = RouteComponentProps<{ nodeId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; sourceId?: string; }>; -const getFieldByNodeType = (nodeType: InfraNodeType, fields: SourceConfigurationFields.Fields) => { +const getFieldByNodeType = ( + nodeType: InventoryItemType, + fields: SourceConfigurationFields.Fields +) => { const inventoryFields = findInventoryFields(nodeType, fields); return inventoryFields.id; }; @@ -75,6 +79,6 @@ export const getNodeLogsUrl = ({ time, }: { nodeId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; time?: number; }) => [`#/link-to/${nodeType}-logs/`, nodeId, ...(time ? [`?time=${time}`] : [])].join(''); diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts index 68c459983bd72f..4449196f2fb537 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/components/helpers.ts @@ -8,13 +8,14 @@ import { ReactText } from 'react'; import Color from 'color'; import { get, first, last, min, max } from 'lodash'; import { createFormatter } from '../../../utils/formatters'; -import { InfraDataSeries, InfraMetricData } from '../../../graphql/types'; +import { InfraDataSeries } from '../../../graphql/types'; import { InventoryVisTypeRT, InventoryFormatterType, InventoryVisType, } from '../../../../common/inventory_models/types'; import { SeriesOverrides } from '../types'; +import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; /** * Returns a formatter @@ -34,7 +35,7 @@ export const seriesHasLessThen2DataPoints = (series: InfraDataSeries): boolean = /** * Returns the minimum and maximum timestamp for a metric */ -export const getMaxMinTimestamp = (metric: InfraMetricData): [number, number] => { +export const getMaxMinTimestamp = (metric: NodeDetailsMetricData): [number, number] => { if (metric.series.some(seriesHasLessThen2DataPoints)) { return [0, 0]; } diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx index 2f4eb57cd5161f..cc662f8fd5b570 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/components/node_details_page.tsx @@ -13,9 +13,9 @@ import { EuiHideFor, EuiTitle, } from '@elastic/eui'; -import { InventoryMetric } from '../../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; +import { InventoryMetric, InventoryItemType } from '../../../../common/inventory_models/types'; import { useNodeDetails } from '../../../containers/node_details/use_node_details'; -import { InfraNodeType, InfraTimerangeInput } from '../../../graphql/types'; import { MetricsSideNav } from './side_nav'; import { AutoSizer } from '../../../components/auto_sizer'; import { MetricsTimeControls } from './time_controls'; @@ -32,7 +32,7 @@ interface Props { requiredMetrics: InventoryMetric[]; nodeId: string; cloudId: string; - nodeType: InfraNodeType; + nodeType: InventoryItemType; sourceId: string; timeRange: MetricsTimeInput; parsedTimeRange: InfraTimerangeInput; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx index 81c96c0ffe68d7..414b9c60adee39 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/components/page_body.tsx @@ -8,16 +8,17 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { findLayout } from '../../../../common/inventory_models/layouts'; import { InventoryItemType } from '../../../../common/inventory_models/types'; -import { InfraMetricData } from '../../../graphql/types'; + import { MetricsTimeInput } from '../containers/with_metrics_time'; import { InfraLoadingPanel } from '../../../components/loading'; import { NoData } from '../../../components/empty_states'; +import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; interface Props { loading: boolean; refetch: () => void; type: InventoryItemType; - metrics: InfraMetricData[]; + metrics: NodeDetailsMetricData[]; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx deleted file mode 100644 index 6f7e411628d27b..00000000000000 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/containers/with_metrics.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { ApolloError } from 'apollo-client'; -import React from 'react'; -import { Query } from 'react-apollo'; -import { i18n } from '@kbn/i18n'; -import { - InfraMetric, - InfraMetricData, - InfraNodeType, - MetricsQuery, - InfraTimerangeInput, -} from '../../../graphql/types'; -import { metricsQuery } from './metrics.gql_query'; -import { InventoryMetric, InventoryMetricRT } from '../../../../common/inventory_models/types'; - -interface WithMetricsArgs { - metrics: InfraMetricData[]; - error?: ApolloError | undefined; - loading: boolean; - refetch: () => void; -} - -interface WithMetricsProps { - children: (args: WithMetricsArgs) => React.ReactNode; - requiredMetrics: InventoryMetric[]; - nodeType: InfraNodeType; - nodeId: string; - cloudId: string; - sourceId: string; - timerange: InfraTimerangeInput; -} - -const isInfraMetrics = (subject: any[]): subject is InfraMetric[] => { - return subject.every(s => InventoryMetricRT.is(s)); -}; - -export const WithMetrics = ({ - children, - requiredMetrics, - sourceId, - timerange, - nodeType, - nodeId, - cloudId, -}: WithMetricsProps) => { - if (!isInfraMetrics(requiredMetrics)) { - throw new Error( - i18n.translate('xpack.infra.invalidInventoryMetricsError', { - defaultMessage: 'One of the InfraMetric is invalid', - }) - ); - } - - return ( - - query={metricsQuery} - fetchPolicy="no-cache" - notifyOnNetworkStatusChange - variables={{ - sourceId, - metrics: requiredMetrics, - nodeType, - nodeId, - cloudId, - timerange, - }} - > - {({ data, error, loading, refetch }) => { - return children({ - metrics: filterOnlyInfraMetricData(data && data.source && data.source.metrics), - error, - loading, - refetch, - }); - }} - - ); -}; - -const filterOnlyInfraMetricData = ( - metrics: Array | undefined -): InfraMetricData[] => { - if (!metrics) { - return []; - } - return metrics.filter(m => m !== null).map(m => m as InfraMetricData); -}; diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx index b7e8274802555a..4c0d2d7c93d7ac 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx @@ -10,7 +10,6 @@ import { DocumentTitle } from '../../components/document_title'; import { Header } from '../../components/header'; import { ColumnarPage, PageContent } from '../../components/page'; import { WithMetricsTime, WithMetricsTimeUrlState } from './containers/with_metrics_time'; -import { InfraNodeType } from '../../graphql/types'; import { withMetricPageProviders } from './page_providers'; import { useMetadata } from '../../containers/metadata/use_metadata'; import { Source } from '../../containers/source'; @@ -19,6 +18,7 @@ import { findInventoryModel } from '../../../common/inventory_models'; import { NavItem } from './lib/side_nav_context'; import { NodeDetailsPage } from './components/node_details_page'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { InventoryItemType } from '../../../common/inventory_models/types'; const DetailPageContent = euiStyled(PageContent)` overflow: auto; @@ -39,7 +39,7 @@ export const MetricDetail = withMetricPageProviders( withTheme(({ match, theme }: Props) => { const uiCapabilities = useKibana().services.application?.capabilities; const nodeId = match.params.node; - const nodeType = match.params.type as InfraNodeType; + const nodeType = match.params.type as InventoryItemType; const inventoryModel = findInventoryModel(nodeType); const { sourceId } = useContext(Source.Context); const { diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts b/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts index e752164796150f..4b9e32fddcc76c 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/types.ts @@ -6,12 +6,12 @@ import rt from 'io-ts'; import { EuiTheme } from '../../../../../common/eui_styled_components'; -import { InfraMetricData } from '../../graphql/types'; import { InventoryFormatterTypeRT } from '../../../common/inventory_models/types'; import { MetricsTimeInput } from './containers/with_metrics_time'; +import { NodeDetailsMetricData } from '../../../common/http_api/node_details_api'; export interface LayoutProps { - metrics?: InfraMetricData[]; + metrics?: NodeDetailsMetricData[]; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; @@ -55,7 +55,7 @@ export const VisSectionPropsRT = rt.partial({ export type VisSectionProps = rt.TypeOf & { id?: string; - metric?: InfraMetricData; + metric?: NodeDetailsMetricData; onChangeRangeTime?: (time: MetricsTimeInput) => void; isLiveStreaming?: boolean; stopLiveStreaming?: () => void; diff --git a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts index d72e9e05f2aeaa..4a1b45084b08a7 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/actions.ts @@ -5,19 +5,16 @@ */ import actionCreatorFactory from 'typescript-fsa'; -import { - InfraSnapshotMetricInput, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../../graphql/types'; +import { SnapshotGroupBy, SnapshotMetricInput } from '../../../../common/http_api/snapshot_api'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib'; const actionCreator = actionCreatorFactory('x-pack/infra/local/waffle_options'); -export const changeMetric = actionCreator('CHANGE_METRIC'); -export const changeGroupBy = actionCreator('CHANGE_GROUP_BY'); +export const changeMetric = actionCreator('CHANGE_METRIC'); +export const changeGroupBy = actionCreator('CHANGE_GROUP_BY'); export const changeCustomOptions = actionCreator('CHANGE_CUSTOM_OPTIONS'); -export const changeNodeType = actionCreator('CHANGE_NODE_TYPE'); +export const changeNodeType = actionCreator('CHANGE_NODE_TYPE'); export const changeView = actionCreator('CHANGE_VIEW'); export const changeBoundsOverride = actionCreator('CHANGE_BOUNDS_OVERRIDE'); export const changeAutoBounds = actionCreator('CHANGE_AUTO_BOUNDS'); diff --git a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts index a8aa91695b3a52..9d86ffe612a288 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/waffle_options/reducer.ts @@ -6,13 +6,7 @@ import { combineReducers } from 'redux'; import { reducerWithInitialState } from 'typescript-fsa-reducers'; - -import { - InfraSnapshotMetricInput, - InfraSnapshotMetricType, - InfraNodeType, - InfraSnapshotGroupbyInput, -} from '../../../graphql/types'; +import { SnapshotMetricInput, SnapshotGroupBy } from '../../../../common/http_api/snapshot_api'; import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib'; import { changeAutoBounds, @@ -25,11 +19,12 @@ import { changeAccount, changeRegion, } from './actions'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; export interface WaffleOptionsState { - metric: InfraSnapshotMetricInput; - groupBy: InfraSnapshotGroupbyInput[]; - nodeType: InfraNodeType; + metric: SnapshotMetricInput; + groupBy: SnapshotGroupBy; + nodeType: InventoryItemType; view: string; customOptions: InfraGroupByOptions[]; boundsOverride: InfraWaffleMapBounds; @@ -39,9 +34,9 @@ export interface WaffleOptionsState { } export const initialWaffleOptionsState: WaffleOptionsState = { - metric: { type: InfraSnapshotMetricType.cpu }, + metric: { type: 'cpu' }, groupBy: [], - nodeType: InfraNodeType.host, + nodeType: 'host', view: 'map', customOptions: [], boundsOverride: { max: 1, min: 0 }, diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts index 844eaf76049277..6659cb060b1a8e 100644 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/adapter_types.ts @@ -6,22 +6,19 @@ import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; import { - InfraMetric, - InfraMetricData, - InfraNodeType, - InfraTimerangeInput, -} from '../../../graphql/types'; + NodeDetailsRequest, + NodeDetailsMetricData, +} from '../../../../common/http_api/node_details_api'; +import { InventoryMetric } from '../../../../common/inventory_models/types'; import { InfraSourceConfiguration } from '../../sources'; -export interface InfraMetricsRequestOptions { +export interface InfraMetricsRequestOptions + extends Omit { nodeIds: { nodeId: string; cloudId?: string | null; }; - nodeType: InfraNodeType; sourceConfiguration: InfraSourceConfiguration; - timerange: InfraTimerangeInput; - metrics: InfraMetric[]; } export interface InfraMetricsAdapter { @@ -29,7 +26,7 @@ export interface InfraMetricsAdapter { requestContext: RequestHandlerContext, options: InfraMetricsRequestOptions, request: KibanaRequest - ): Promise; + ): Promise; } export enum InfraMetricModelQueryType { @@ -52,7 +49,7 @@ export enum InfraMetricModelMetricType { } export interface InfraMetricModel { - id: InfraMetric; + id: InventoryMetric; requires: string[]; index_pattern: string | string[]; interval: string; diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts index 6acb8afbfb2491..eb5ac05644a227 100644 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts +++ b/x-pack/legacy/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts @@ -7,12 +7,16 @@ import { i18n } from '@kbn/i18n'; import { flatten, get } from 'lodash'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { InfraMetric, InfraMetricData } from '../../../graphql/types'; +import { NodeDetailsMetricData } from '../../../../common/http_api/node_details_api'; import { KibanaFramework } from '../framework/kibana_framework_adapter'; import { InfraMetricsAdapter, InfraMetricsRequestOptions } from './adapter_types'; import { checkValidNode } from './lib/check_valid_node'; import { metrics, findInventoryFields } from '../../../../common/inventory_models'; -import { TSVBMetricModelCreator } from '../../../../common/inventory_models/types'; +import { + TSVBMetricModelCreator, + InventoryMetric, + InventoryMetricRT, +} from '../../../../common/inventory_models/types'; import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; export class KibanaMetricsAdapter implements InfraMetricsAdapter { @@ -26,7 +30,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { requestContext: RequestHandlerContext, options: InfraMetricsRequestOptions, rawRequest: KibanaRequest - ): Promise { + ): Promise { const indexPattern = `${options.sourceConfiguration.metricAlias},${options.sourceConfiguration.logAlias}`; const fields = findInventoryFields(options.nodeType, options.sourceConfiguration.fields); const nodeField = fields.id; @@ -58,8 +62,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { ); return metricIds.map((id: string) => { - const infraMetricId: InfraMetric = (InfraMetric as any)[id]; - if (!infraMetricId) { + if (!InventoryMetricRT.is(id)) { throw new Error( i18n.translate('xpack.infra.kibanaMetrics.invalidInfraMetricErrorMessage', { defaultMessage: '{id} is not a valid InfraMetric', @@ -69,9 +72,9 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { }) ); } - const panel = result[infraMetricId]; + const panel = result[id]; return { - id: infraMetricId, + id, series: panel.series.map(series => { return { id: series.id, @@ -87,7 +90,7 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { } async makeTSVBRequest( - metricId: InfraMetric, + metricId: InventoryMetric, options: InfraMetricsRequestOptions, nodeField: string, requestContext: RequestHandlerContext diff --git a/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts b/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts index e53e45afae5c48..ac76e264ff0ed3 100644 --- a/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts +++ b/x-pack/legacy/plugins/infra/server/lib/domains/metrics_domain.ts @@ -5,8 +5,8 @@ */ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { InfraMetricData } from '../../graphql/types'; import { InfraMetricsAdapter, InfraMetricsRequestOptions } from '../adapters/metrics/adapter_types'; +import { NodeDetailsMetricData } from '../../../common/http_api/node_details_api'; export class InfraMetricsDomain { private adapter: InfraMetricsAdapter; @@ -19,7 +19,7 @@ export class InfraMetricsDomain { requestContext: RequestHandlerContext, options: InfraMetricsRequestOptions, rawRequest: KibanaRequest - ): Promise { + ): Promise { return await this.adapter.getMetrics(requestContext, options, rawRequest); } } diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts index 4bb1aefb5b2c72..6f036475a1e1cf 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts @@ -7,12 +7,12 @@ import { uniq } from 'lodash'; import { RequestHandlerContext } from 'kibana/server'; import { InfraSnapshotRequestOptions } from './types'; -import { InfraTimerangeInput } from '../../../public/graphql/types'; import { getMetricsAggregations } from './query_helpers'; import { calculateMetricInterval } from '../../utils/calculate_metric_interval'; import { SnapshotModel, SnapshotModelMetricAggRT } from '../../../common/inventory_models/types'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field'; +import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; export const createTimeRangeWithInterval = async ( framework: KibanaFramework, diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts index 28146624a8a894..20220aef1133df 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.test.ts @@ -5,7 +5,6 @@ */ import { isIPv4, getIPFromBucket, InfraSnapshotNodeGroupByBucket } from './response_helpers'; -import { InfraNodeType } from '../../graphql/types'; describe('InfraOps ResponseHelpers', () => { describe('isIPv4', () => { @@ -44,7 +43,7 @@ describe('InfraOps ResponseHelpers', () => { }, }, }; - expect(getIPFromBucket(InfraNodeType.host, bucket)).toBe('192.168.1.4'); + expect(getIPFromBucket('host', bucket)).toBe('192.168.1.4'); }); it('should NOT return ipv6 address', () => { const bucket: InfraSnapshotNodeGroupByBucket = { @@ -72,7 +71,7 @@ describe('InfraOps ResponseHelpers', () => { }, }, }; - expect(getIPFromBucket(InfraNodeType.host, bucket)).toBe(null); + expect(getIPFromBucket('host', bucket)).toBe(null); }); }); }); diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts index d22f41ff152f76..12f284c363bd53 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/response_helpers.ts @@ -7,15 +7,11 @@ import { isNumber, last, max, sum, get } from 'lodash'; import moment from 'moment'; -import { - InfraSnapshotMetricType, - InfraSnapshotNodePath, - InfraSnapshotNodeMetric, - InfraNodeType, -} from '../../graphql/types'; import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds'; import { InfraSnapshotRequestOptions } from './types'; import { findInventoryModel } from '../../../common/inventory_models'; +import { InventoryItemType, SnapshotMetricType } from '../../../common/inventory_models/types'; +import { SnapshotNodeMetric, SnapshotNodePath } from '../../../common/http_api/snapshot_api'; export interface InfraSnapshotNodeMetricsBucket { key: { id: string }; @@ -70,7 +66,7 @@ export interface InfraSnapshotNodeGroupByBucket { export const isIPv4 = (subject: string) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(subject); export const getIPFromBucket = ( - nodeType: InfraNodeType, + nodeType: InventoryItemType, bucket: InfraSnapshotNodeGroupByBucket ): string | null => { const inventoryModel = findInventoryModel(nodeType); @@ -92,10 +88,10 @@ export const getIPFromBucket = ( export const getNodePath = ( groupBucket: InfraSnapshotNodeGroupByBucket, options: InfraSnapshotRequestOptions -): InfraSnapshotNodePath[] => { +): SnapshotNodePath[] => { const node = groupBucket.key; const path = options.groupBy.map(gb => { - return { value: node[`${gb.field}`], label: node[`${gb.field}`] } as InfraSnapshotNodePath; + return { value: node[`${gb.field}`], label: node[`${gb.field}`] } as SnapshotNodePath; }); const ip = getIPFromBucket(options.nodeType, groupBucket); path.push({ value: node.id, label: node.name || node.id, ip }); @@ -121,7 +117,7 @@ export const getNodeMetricsForLookup = ( export const getNodeMetrics = ( nodeBuckets: InfraSnapshotMetricsBucket[], options: InfraSnapshotRequestOptions -): InfraSnapshotNodeMetric => { +): SnapshotNodeMetric => { if (!nodeBuckets) { return { name: options.metric.type, @@ -156,18 +152,15 @@ const findLastFullBucket = ( }, last(buckets)); }; -const getMetricValueFromBucket = ( - type: InfraSnapshotMetricType, - bucket: InfraSnapshotMetricsBucket -) => { +const getMetricValueFromBucket = (type: SnapshotMetricType, bucket: InfraSnapshotMetricsBucket) => { const metric = bucket[type]; return (metric && (metric.normalized_value || metric.value)) || 0; }; -function calculateMax(buckets: InfraSnapshotMetricsBucket[], type: InfraSnapshotMetricType) { +function calculateMax(buckets: InfraSnapshotMetricsBucket[], type: SnapshotMetricType) { return max(buckets.map(bucket => getMetricValueFromBucket(type, bucket))) || 0; } -function calculateAvg(buckets: InfraSnapshotMetricsBucket[], type: InfraSnapshotMetricType) { +function calculateAvg(buckets: InfraSnapshotMetricsBucket[], type: SnapshotMetricType) { return sum(buckets.map(bucket => getMetricValueFromBucket(type, bucket))) / buckets.length || 0; } diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts index 1a724673608a27..ef453be76d8d73 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/snapshot.ts @@ -5,7 +5,6 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { InfraSnapshotNode } from '../../graphql/types'; import { InfraDatabaseSearchResponse } from '../adapters/framework'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { InfraSources } from '../sources'; @@ -30,6 +29,7 @@ import { createAfterKeyHandler } from '../../utils/create_afterkey_handler'; import { findInventoryModel } from '../../../common/inventory_models'; import { InfraSnapshotRequestOptions } from './types'; import { createTimeRangeWithInterval } from './create_timerange_with_interval'; +import { SnapshotNode } from '../../../common/http_api/snapshot_api'; export class InfraSnapshot { constructor(private readonly libs: { sources: InfraSources; framework: KibanaFramework }) {} @@ -37,7 +37,7 @@ export class InfraSnapshot { public async getNodes( requestContext: RequestHandlerContext, options: InfraSnapshotRequestOptions - ): Promise<{ nodes: InfraSnapshotNode[]; interval: string }> { + ): Promise<{ nodes: SnapshotNode[]; interval: string }> { // Both requestGroupedNodes and requestNodeMetrics may send several requests to elasticsearch // in order to page through the results of their respective composite aggregations. // Both chains of requests are supposed to run in parallel, and their results be merged @@ -186,7 +186,7 @@ const mergeNodeBuckets = ( nodeGroupByBuckets: InfraSnapshotNodeGroupByBucket[], nodeMetricsBuckets: InfraSnapshotNodeMetricsBucket[], options: InfraSnapshotRequestOptions -): InfraSnapshotNode[] => { +): SnapshotNode[] => { const nodeMetricsForLookup = getNodeMetricsForLookup(nodeMetricsBuckets); return nodeGroupByBuckets.map(node => { diff --git a/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts b/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts index 31823b2811121e..3627747e327e03 100644 --- a/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/snapshot/types.ts @@ -5,21 +5,11 @@ */ import { JsonObject } from '../../../common/typed_json'; -import { - InfraNodeType, - InfraSourceConfiguration, - InfraTimerangeInput, - InfraSnapshotGroupbyInput, - InfraSnapshotMetricInput, -} from '../../../public/graphql/types'; +import { InfraSourceConfiguration } from '../../../public/graphql/types'; +import { SnapshotRequest } from '../../../common/http_api/snapshot_api'; -export interface InfraSnapshotRequestOptions { - nodeType: InfraNodeType; +export interface InfraSnapshotRequestOptions + extends Omit { sourceConfiguration: InfraSourceConfiguration; - timerange: InfraTimerangeInput; - groupBy: InfraSnapshotGroupbyInput[]; - metric: InfraSnapshotMetricInput; filterQuery: JsonObject | undefined; - accountId?: string; - region?: string; } diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts index 4ff0df30abedd8..751e4941647561 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts @@ -8,7 +8,6 @@ import { first, set, startsWith } from 'lodash'; import { RequestHandlerContext } from 'src/core/server'; import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; import { InfraSourceConfiguration } from '../../../lib/sources'; -import { InfraNodeType } from '../../../graphql/types'; import { InfraMetadataInfo } from '../../../../common/http_api/metadata_api'; import { getPodNodeName } from './get_pod_node_name'; import { CLOUD_METRICS_MODULES } from '../../../lib/constants'; @@ -27,7 +26,7 @@ export const getNodeInfo = async ( // can report pod details and we can't rely on the host/cloud information associated // with the kubernetes.pod.uid. We need to first lookup the `kubernetes.node.name` // then use that to lookup the host's node information. - if (nodeType === InfraNodeType.pod) { + if (nodeType === 'pod') { const kubernetesNodeName = await getPodNodeName( framework, requestContext, @@ -41,7 +40,7 @@ export const getNodeInfo = async ( requestContext, sourceConfiguration, kubernetesNodeName, - InfraNodeType.host + 'host' ); } return {}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts b/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts index 9e5fe16d482b2d..7e47f50c760882 100644 --- a/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts +++ b/x-pack/legacy/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts @@ -6,11 +6,10 @@ import { InfraMetricModelMetricType } from '../../../lib/adapters/metrics'; import { MetricsExplorerRequestBody } from '../types'; -import { InfraMetric } from '../../../graphql/types'; import { TSVBMetricModel } from '../../../../common/inventory_models/types'; export const createMetricModel = (options: MetricsExplorerRequestBody): TSVBMetricModel => { return { - id: InfraMetric.custom, + id: 'custom', requires: [], index_pattern: options.indexPattern, interval: options.timerange.interval, diff --git a/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts b/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts index a9419cd27e6840..4a09615f0a17c2 100644 --- a/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/node_details/index.ts @@ -11,7 +11,7 @@ import { identity } from 'fp-ts/lib/function'; import { InfraBackendLibs } from '../../lib/infra_types'; import { UsageCollector } from '../../usage/usage_collector'; import { InfraMetricsRequestOptions } from '../../lib/adapters/metrics'; -import { InfraNodeType, InfraMetric } from '../../graphql/types'; + import { NodeDetailsRequestRT, NodeDetailsMetricDataResponseRT, @@ -46,9 +46,9 @@ export const initNodeDetailsRoute = (libs: InfraBackendLibs) => { nodeId, cloudId, }, - nodeType: nodeType as InfraNodeType, + nodeType, sourceConfiguration: source.configuration, - metrics: metrics as InfraMetric[], + metrics, timerange, }; return response.ok({ diff --git a/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts b/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts index ba7f52e9ec1e7e..5f28e41d80c258 100644 --- a/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/snapshot/index.ts @@ -11,10 +11,8 @@ import { identity } from 'fp-ts/lib/function'; import { InfraBackendLibs } from '../../lib/infra_types'; import { UsageCollector } from '../../usage/usage_collector'; import { parseFilterQuery } from '../../utils/serialized_query'; -import { InfraNodeType, InfraSnapshotMetricInput } from '../../../public/graphql/types'; import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api'; import { throwErrors } from '../../../common/runtime_types'; -import { InfraSnapshotRequestOptions } from '../../lib/snapshot/types'; const escapeHatch = schema.object({}, { allowUnknowns: true }); @@ -46,16 +44,14 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { ); const source = await libs.sources.getSourceConfiguration(requestContext, sourceId); UsageCollector.countNode(nodeType); - const options: InfraSnapshotRequestOptions = { + const options = { filterQuery: parseFilterQuery(filterQuery), accountId, region, - // TODO: Use common infra metric and replace graphql type - nodeType: nodeType as InfraNodeType, + nodeType, groupBy, sourceConfiguration: source.configuration, - // TODO: Use common infra metric and replace graphql type - metric: metric as InfraSnapshotMetricInput, + metric, timerange, }; const nodesWithInterval = await libs.snapshot.getNodes(requestContext, options); diff --git a/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts b/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts index 60b9372b135df0..26578bfd2b7941 100644 --- a/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts +++ b/x-pack/legacy/plugins/infra/server/usage/usage_collector.ts @@ -5,7 +5,6 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { InfraNodeType } from '../graphql/types'; import { InventoryItemType } from '../../common/inventory_models/types'; const KIBANA_REPORTING_TYPE = 'infraops'; @@ -38,10 +37,10 @@ export class UsageCollector { this.maybeInitializeBucket(bucket); switch (nodeType) { - case InfraNodeType.pod: + case 'pod': this.counters[bucket].infraopsKubernetes += 1; break; - case InfraNodeType.container: + case 'container': this.counters[bucket].infraopsDocker += 1; break; default: diff --git a/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts b/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts index 586193a3c242db..7cbbdc0f2145be 100644 --- a/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts +++ b/x-pack/legacy/plugins/infra/server/utils/calculate_metric_interval.ts @@ -5,9 +5,9 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { InfraNodeType } from '../graphql/types'; import { findInventoryModel } from '../../common/inventory_models'; import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter'; +import { InventoryItemType } from '../../common/inventory_models/types'; interface Options { indexPattern: string; @@ -27,7 +27,7 @@ export const calculateMetricInterval = async ( requestContext: RequestHandlerContext, options: Options, modules?: string[], - nodeType?: InfraNodeType // TODO: check that this type still makes sense + nodeType?: InventoryItemType // TODO: check that this type still makes sense ) => { let from = options.timerange.from; if (nodeType) { diff --git a/x-pack/legacy/plugins/infra/types/eui.d.ts b/x-pack/legacy/plugins/infra/types/eui.d.ts index afcb445a66adb6..e73a73076923d4 100644 --- a/x-pack/legacy/plugins/infra/types/eui.d.ts +++ b/x-pack/legacy/plugins/infra/types/eui.d.ts @@ -34,7 +34,7 @@ declare module '@elastic/eui' { items: Array<{ id: string; name: string; - onClick: () => void; + onClick?: () => void; }>; }>; mobileTitle?: React.ReactNode; diff --git a/x-pack/legacy/plugins/license_management/public/register_route.ts b/x-pack/legacy/plugins/license_management/public/register_route.ts index 0fe29b543be5bb..fc1678a866ad31 100644 --- a/x-pack/legacy/plugins/license_management/public/register_route.ts +++ b/x-pack/legacy/plugins/license_management/public/register_route.ts @@ -87,6 +87,7 @@ if (licenseManagementUiEnabled) { const unmountApp = await app.mount({ ...npStart } as any, { element, appBasePath: '', + onAppLeave: () => undefined, }); manageAngularLifecycle($scope, $route, unmountApp as any); }, diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js index 053b389a590110..f05b7eba9e7e01 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js @@ -39,7 +39,7 @@ export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS'; export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER'; export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST'; export const REMOVE_LAYER = 'REMOVE_LAYER'; -export const TOGGLE_LAYER_VISIBLE = 'TOGGLE_LAYER_VISIBLE'; +export const SET_LAYER_VISIBILITY = 'SET_LAYER_VISIBILITY'; export const MAP_EXTENT_CHANGED = 'MAP_EXTENT_CHANGED'; export const MAP_READY = 'MAP_READY'; export const MAP_DESTROYED = 'MAP_DESTROYED'; @@ -72,6 +72,7 @@ export const DISABLE_TOOLTIP_CONTROL = 'DISABLE_TOOLTIP_CONTROL'; export const HIDE_TOOLBAR_OVERLAY = 'HIDE_TOOLBAR_OVERLAY'; export const HIDE_LAYER_CONTROL = 'HIDE_LAYER_CONTROL'; export const HIDE_VIEW_CONTROL = 'HIDE_VIEW_CONTROL'; +export const SET_WAITING_FOR_READY_HIDDEN_LAYERS = 'SET_WAITING_FOR_READY_HIDDEN_LAYERS'; function getLayerLoadingCallbacks(dispatch, layerId) { return { @@ -252,23 +253,25 @@ export function cleanTooltipStateForLayer(layerId, layerFeatures = []) { }; } -export function toggleLayerVisible(layerId) { +export function setLayerVisibility(layerId, makeVisible) { return async (dispatch, getState) => { //if the current-state is invisible, we also want to sync data //e.g. if a layer was invisible at start-up, it won't have any data loaded const layer = getLayerById(layerId, getState()); - if (!layer) { + + // If the layer visibility is already what we want it to be, do nothing + if (!layer || layer.isVisible() === makeVisible) { return; } - const makeVisible = !layer.isVisible(); if (!makeVisible) { dispatch(cleanTooltipStateForLayer(layerId)); } await dispatch({ - type: TOGGLE_LAYER_VISIBLE, + type: SET_LAYER_VISIBILITY, layerId, + visibility: makeVisible, }); if (makeVisible) { dispatch(syncDataForLayer(layerId)); @@ -276,6 +279,18 @@ export function toggleLayerVisible(layerId) { }; } +export function toggleLayerVisible(layerId) { + return async (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + const makeVisible = !layer.isVisible(); + + dispatch(setLayerVisibility(layerId, makeVisible)); + }; +} + export function setSelectedLayer(layerId) { return async (dispatch, getState) => { const oldSelectedLayer = getSelectedLayerId(getState()); @@ -840,3 +855,17 @@ export function hideLayerControl() { export function hideViewControl() { return { type: HIDE_VIEW_CONTROL, hideViewControl: true }; } + +export function setHiddenLayers(hiddenLayerIds) { + return (dispatch, getState) => { + const isMapReady = getMapReady(getState()); + + if (!isMapReady) { + dispatch({ type: SET_WAITING_FOR_READY_HIDDEN_LAYERS, hiddenLayerIds }); + } else { + getLayerListRaw(getState()).forEach(layer => + dispatch(setLayerVisibility(layer.id, !hiddenLayerIds.includes(layer.id))) + ); + } + }; +} diff --git a/x-pack/legacy/plugins/maps/public/embeddable/README.md b/x-pack/legacy/plugins/maps/public/embeddable/README.md index eb6571a96016c7..1de327702fb87a 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/README.md +++ b/x-pack/legacy/plugins/maps/public/embeddable/README.md @@ -9,6 +9,7 @@ - **hideToolbarOverlay:** (Boolean) Will disable toolbar, which can be used to navigate to coordinate by entering lat/long and zoom values. - **hideLayerControl:** (Boolean) Will hide useful layer control, which can be used to hide/show a layer to get a refined view of the map. - **hideViewControl:** (Boolean) Will hide view control at bottom right of the map, which shows lat/lon values based on mouse hover in the map, this is useful to get coordinate value from a particular point in map. +- **hiddenLayers:** (Array of Strings) Array of layer ids that should be hidden. Any other layers will be set to visible regardless of their value in the layerList used to initialize the embeddable ### Creating a Map embeddable from saved object ``` diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js index 2ee766f91fbca2..c723e996ee6797 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js @@ -32,11 +32,12 @@ import { hideToolbarOverlay, hideLayerControl, hideViewControl, + setHiddenLayers, } from '../actions/map_actions'; import { setReadOnly, setIsLayerTOCOpen, setOpenTOCDetails } from '../actions/ui_actions'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; import { getInspectorAdapters, setEventHandlers } from '../reducers/non_serializable_instances'; -import { getMapCenter, getMapZoom } from '../selectors/map_selectors'; +import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_selectors'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; export class MapEmbeddable extends Embeddable { @@ -153,6 +154,9 @@ export class MapEmbeddable extends Embeddable { } this._store.dispatch(replaceLayerList(this._layerList)); + if (this.input.hiddenLayers) { + this._store.dispatch(setHiddenLayers(this.input.hiddenLayers)); + } this._dispatchSetQuery(this.input); this._dispatchSetRefreshConfig(this.input); @@ -244,5 +248,13 @@ export class MapEmbeddable extends Embeddable { openTOCDetails, }); } + + const hiddenLayerIds = getHiddenLayerIds(this._store.getState()); + + if (!_.isEqual(this.input.hiddenLayers, hiddenLayerIds)) { + this.updateInput({ + hiddenLayers: hiddenLayerIds, + }); + } } } diff --git a/x-pack/legacy/plugins/maps/public/reducers/map.js b/x-pack/legacy/plugins/maps/public/reducers/map.js index 7dd60f013cefde..ac409c685c71af 100644 --- a/x-pack/legacy/plugins/maps/public/reducers/map.js +++ b/x-pack/legacy/plugins/maps/public/reducers/map.js @@ -16,7 +16,7 @@ import { ADD_WAITING_FOR_MAP_READY_LAYER, CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST, REMOVE_LAYER, - TOGGLE_LAYER_VISIBLE, + SET_LAYER_VISIBILITY, MAP_EXTENT_CHANGED, MAP_READY, MAP_DESTROYED, @@ -46,6 +46,7 @@ import { HIDE_TOOLBAR_OVERLAY, HIDE_LAYER_CONTROL, HIDE_VIEW_CONTROL, + SET_WAITING_FOR_READY_HIDDEN_LAYERS, } from '../actions/map_actions'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util'; @@ -307,8 +308,8 @@ export function map(state = INITIAL_STATE, action) { ...state, waitingForMapReadyLayerList: [], }; - case TOGGLE_LAYER_VISIBLE: - return updateLayerInList(state, action.layerId, 'visible'); + case SET_LAYER_VISIBILITY: + return updateLayerInList(state, action.layerId, 'visible', action.visibility); case UPDATE_LAYER_STYLE: const styleLayerId = action.layerId; return updateLayerInList(state, styleLayerId, 'style', { ...action.style }); @@ -376,6 +377,14 @@ export function map(state = INITIAL_STATE, action) { hideViewControl: action.hideViewControl, }, }; + case SET_WAITING_FOR_READY_HIDDEN_LAYERS: + return { + ...state, + waitingForMapReadyLayerList: state.waitingForMapReadyLayerList.map(layer => ({ + ...layer, + visible: !action.hiddenLayerIds.includes(layer.id), + })), + }; default: return state; } diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js index 3d8e6f97ef077d..4b3d1355e4264f 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js @@ -150,6 +150,10 @@ export const getLayerList = createSelector( } ); +export const getHiddenLayerIds = createSelector(getLayerListRaw, layers => + layers.filter(layer => !layer.visible).map(layer => layer.id) +); + export const getSelectedLayer = createSelector( getSelectedLayerId, getLayerList, diff --git a/x-pack/legacy/plugins/ml/common/constants/new_job.ts b/x-pack/legacy/plugins/ml/common/constants/new_job.ts index 004824bef1c9d1..ccd108cd2698f4 100644 --- a/x-pack/legacy/plugins/ml/common/constants/new_job.ts +++ b/x-pack/legacy/plugins/ml/common/constants/new_job.ts @@ -9,16 +9,24 @@ export enum JOB_TYPE { MULTI_METRIC = 'multi_metric', POPULATION = 'population', ADVANCED = 'advanced', + CATEGORIZATION = 'categorization', } export enum CREATED_BY_LABEL { SINGLE_METRIC = 'single-metric-wizard', MULTI_METRIC = 'multi-metric-wizard', POPULATION = 'population-wizard', + CATEGORIZATION = 'categorization-wizard', } export const DEFAULT_MODEL_MEMORY_LIMIT = '10MB'; export const DEFAULT_BUCKET_SPAN = '15m'; +export const DEFAULT_RARE_BUCKET_SPAN = '1h'; export const DEFAULT_QUERY_DELAY = '60s'; export const SHARED_RESULTS_INDEX_NAME = 'shared'; + +export const NUMBER_OF_CATEGORY_EXAMPLES = 5; +export const CATEGORY_EXAMPLES_MULTIPLIER = 20; +export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; +export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.2; diff --git a/x-pack/legacy/plugins/ml/common/types/categories.ts b/x-pack/legacy/plugins/ml/common/types/categories.ts new file mode 100644 index 00000000000000..6ccd13ed9a39ec --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/types/categories.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type CategoryId = number; + +export interface Category { + job_id: string; + category_id: CategoryId; + terms: string; + regex: string; + max_matching_length: number; + examples: string[]; + grok_pattern: string; +} + +export interface Token { + token: string; + start_offset: number; + end_offset: number; + type: string; + position: number; +} diff --git a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index aa28831e8d8074..a28dc41fa1790f 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -37,8 +37,7 @@ function useRefWithCallback() { if (left + tooltipWidth > contentWidth) { // the tooltip is hanging off the side of the page, // so move it to the other side of the target - const markerWidthAdjustment = 25; - left = left - (tooltipWidth + offset.x + markerWidthAdjustment); + left = left - (tooltipWidth + offset.x); } const top = targetPosition.top + offset.y - parentBounding.top; diff --git a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap index 0e14f0b402fb29..d633b755ec6bab 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap @@ -3,7 +3,6 @@ exports[`FullTimeRangeSelector renders the selector 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx index ef2a4c28a570d1..4460ced7079c3d 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/full_time_range_selector/full_time_range_selector.tsx @@ -31,7 +31,6 @@ export const FullTimeRangeSelector: FC = ({ indexPattern, query, disabled } return ( setRange(indexPattern, query)} data-test-subj="mlButtonUseFullData" diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap index fb6b9c7c9db66c..f7752a1ae25a1c 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap @@ -86,7 +86,7 @@ exports[`ValidateJob renders the button 1`] = ` iconSide="right" iconType="questionInCircle" isDisabled={false} - isLoading={false} + isLoading={true} onClick={[Function]} size="s" > diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js index 9b92265c4034bc..a5ed7c3753b2f0 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -21,6 +21,9 @@ import { EuiOverlayMask, EuiSpacer, EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -38,7 +41,7 @@ const defaultIconType = 'questionInCircle'; const getDefaultState = () => ({ ui: { iconType: defaultIconType, - isLoading: false, + isLoading: true, isModalVisible: false, }, data: { @@ -150,6 +153,14 @@ Callout.propTypes = { }), }; +const LoadingSpinner = () => ( + + + + + +); + const Modal = ({ close, title, children }) => ( @@ -249,10 +260,11 @@ export class ValidateJob extends Component { const isDisabled = this.props.isDisabled !== true ? false : true; const embedded = this.props.embedded === true; const idFilterList = this.props.idFilterList || []; + const isLoading = this.state.ui.isLoading; return ( - {embedded === false && ( + {embedded === false ? (
} > - + {isLoading ? ( + + ) : ( + + )} )}
- )} - {embedded === true && ( - + ) : ( + + {isLoading ? ( + + ) : ( + + )} + )}
); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts new file mode 100644 index 00000000000000..cea99eb5ec64c2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/categorization_job_creator.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEqual } from 'lodash'; +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { SavedSearchSavedObject } from '../../../../../../common/types/kibana'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, mlCategory } from '../../../../../../common/types/fields'; +import { Job, Datafeed, Detector } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { + JOB_TYPE, + CREATED_BY_LABEL, + DEFAULT_BUCKET_SPAN, + DEFAULT_RARE_BUCKET_SPAN, +} from '../../../../../../common/constants/new_job'; +import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types'; +import { getRichDetectors } from './util/general'; +import { CategorizationExamplesLoader, CategoryExample } from '../results_loader'; +import { CategorizationAnalyzer, getNewJobDefaults } from '../../../../services/ml_server_info'; + +type CategorizationAnalyzerType = CategorizationAnalyzer | null; + +export class CategorizationJobCreator extends JobCreator { + protected _type: JOB_TYPE = JOB_TYPE.CATEGORIZATION; + private _createCountDetector: () => void = () => {}; + private _createRareDetector: () => void = () => {}; + private _examplesLoader: CategorizationExamplesLoader; + private _categoryFieldExamples: CategoryExample[] = []; + private _categoryFieldValid: number = 0; + private _detectorType: ML_JOB_AGGREGATION.COUNT | ML_JOB_AGGREGATION.RARE = + ML_JOB_AGGREGATION.COUNT; + private _categorizationAnalyzer: CategorizationAnalyzerType = null; + private _defaultCategorizationAnalyzer: CategorizationAnalyzerType; + + constructor( + indexPattern: IndexPattern, + savedSearch: SavedSearchSavedObject | null, + query: object + ) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.CATEGORIZATION; + this._examplesLoader = new CategorizationExamplesLoader(this, indexPattern, query); + + const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults(); + this._defaultCategorizationAnalyzer = anomalyDetectors.categorization_analyzer || null; + } + + public setDefaultDetectorProperties( + count: Aggregation | null, + rare: Aggregation | null, + eventRate: Field | null + ) { + if (count === null || rare === null || eventRate === null) { + return; + } + + this._createCountDetector = () => { + this._createDetector(count, eventRate); + }; + this._createRareDetector = () => { + this._createDetector(rare, eventRate); + }; + } + + private _createDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + dtr.by_field_name = mlCategory.id; + this._addDetector(dtr, agg, mlCategory); + } + + public setDetectorType(type: ML_JOB_AGGREGATION.COUNT | ML_JOB_AGGREGATION.RARE) { + this._detectorType = type; + this.removeAllDetectors(); + if (type === ML_JOB_AGGREGATION.COUNT) { + this._createCountDetector(); + this.bucketSpan = DEFAULT_BUCKET_SPAN; + } else { + this._createRareDetector(); + this.bucketSpan = DEFAULT_RARE_BUCKET_SPAN; + this.modelPlot = false; + } + } + + public set categorizationFieldName(fieldName: string | null) { + if (fieldName !== null) { + this._job_config.analysis_config.categorization_field_name = fieldName; + this.setDetectorType(this._detectorType); + this.addInfluencer(mlCategory.id); + } else { + delete this._job_config.analysis_config.categorization_field_name; + this._categoryFieldExamples = []; + this._categoryFieldValid = 0; + } + } + + public get categorizationFieldName(): string | null { + return this._job_config.analysis_config.categorization_field_name || null; + } + + public async loadCategorizationFieldExamples() { + const { valid, examples } = await this._examplesLoader.loadExamples(); + this._categoryFieldExamples = examples; + this._categoryFieldValid = valid; + return { valid, examples }; + } + + public get categoryFieldExamples() { + return this._categoryFieldExamples; + } + + public get categoryFieldValid() { + return this._categoryFieldValid; + } + + public get selectedDetectorType() { + return this._detectorType; + } + + public set categorizationAnalyzer(analyzer: CategorizationAnalyzerType) { + this._categorizationAnalyzer = analyzer; + + if ( + analyzer === null || + isEqual(this._categorizationAnalyzer, this._defaultCategorizationAnalyzer) + ) { + delete this._job_config.analysis_config.categorization_analyzer; + } else { + this._job_config.analysis_config.categorization_analyzer = analyzer; + } + } + + public get categorizationAnalyzer() { + return this._categorizationAnalyzer; + } + + public cloneFromExistingJob(job: Job, datafeed: Datafeed) { + this._overrideConfigs(job, datafeed); + this.createdBy = CREATED_BY_LABEL.CATEGORIZATION; + const detectors = getRichDetectors(job, datafeed, this.scriptFields, false); + + const dtr = detectors[0]; + if (detectors.length && dtr.agg !== null && dtr.field !== null) { + this._detectorType = + dtr.agg.id === ML_JOB_AGGREGATION.COUNT + ? ML_JOB_AGGREGATION.COUNT + : ML_JOB_AGGREGATION.RARE; + + const bs = job.analysis_config.bucket_span; + this.setDetectorType(this._detectorType); + // set the bucketspan back to the original value + // as setDetectorType applies a default + this.bucketSpan = bs; + } + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts index 8422223ad91fb7..88bacdf49c38a0 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/index.ts @@ -9,11 +9,13 @@ export { SingleMetricJobCreator } from './single_metric_job_creator'; export { MultiMetricJobCreator } from './multi_metric_job_creator'; export { PopulationJobCreator } from './population_job_creator'; export { AdvancedJobCreator } from './advanced_job_creator'; +export { CategorizationJobCreator } from './categorization_job_creator'; export { JobCreatorType, isSingleMetricJobCreator, isMultiMetricJobCreator, isPopulationJobCreator, isAdvancedJobCreator, + isCategorizationJobCreator, } from './type_guards'; export { jobCreatorFactory } from './job_creator_factory'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts index 868346d0188ea2..8655b83a244ad1 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator_factory.ts @@ -10,6 +10,7 @@ import { MultiMetricJobCreator } from './multi_metric_job_creator'; import { PopulationJobCreator } from './population_job_creator'; import { AdvancedJobCreator } from './advanced_job_creator'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { CategorizationJobCreator } from './categorization_job_creator'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; @@ -32,6 +33,9 @@ export const jobCreatorFactory = (jobType: JOB_TYPE) => ( case JOB_TYPE.ADVANCED: jc = AdvancedJobCreator; break; + case JOB_TYPE.CATEGORIZATION: + jc = CategorizationJobCreator; + break; default: jc = SingleMetricJobCreator; break; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts index 9feb0416dd267f..25ea80e18eeb3e 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/type_guards.ts @@ -8,13 +8,15 @@ import { SingleMetricJobCreator } from './single_metric_job_creator'; import { MultiMetricJobCreator } from './multi_metric_job_creator'; import { PopulationJobCreator } from './population_job_creator'; import { AdvancedJobCreator } from './advanced_job_creator'; +import { CategorizationJobCreator } from './categorization_job_creator'; import { JOB_TYPE } from '../../../../../../common/constants/new_job'; export type JobCreatorType = | SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator - | AdvancedJobCreator; + | AdvancedJobCreator + | CategorizationJobCreator; export function isSingleMetricJobCreator( jobCreator: JobCreatorType @@ -37,3 +39,9 @@ export function isPopulationJobCreator( export function isAdvancedJobCreator(jobCreator: JobCreatorType): jobCreator is AdvancedJobCreator { return jobCreator.type === JOB_TYPE.ADVANCED; } + +export function isCategorizationJobCreator( + jobCreator: JobCreatorType +): jobCreator is CategorizationJobCreator { + return jobCreator.type === JOB_TYPE.CATEGORIZATION; +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 760dbe447dc894..6443539a9877da 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { Job, Datafeed, Detector } from '../configs'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { @@ -18,7 +19,12 @@ import { mlCategory, } from '../../../../../../../common/types/fields'; import { mlJobService } from '../../../../../services/job_service'; -import { JobCreatorType, isMultiMetricJobCreator, isPopulationJobCreator } from '../index'; +import { + JobCreatorType, + isMultiMetricJobCreator, + isPopulationJobCreator, + isCategorizationJobCreator, +} from '../index'; import { CREATED_BY_LABEL, JOB_TYPE } from '../../../../../../../common/constants/new_job'; const getFieldByIdFactory = (scriptFields: Field[]) => (id: string) => { @@ -250,6 +256,8 @@ export function convertToAdvancedJob(jobCreator: JobCreatorType) { jobType = JOB_TYPE.MULTI_METRIC; } else if (isPopulationJobCreator(jobCreator)) { jobType = JOB_TYPE.POPULATION; + } else if (isCategorizationJobCreator(jobCreator)) { + jobType = JOB_TYPE.CATEGORIZATION; } window.location.href = window.location.href.replace(jobType, JOB_TYPE.ADVANCED); @@ -270,3 +278,30 @@ export function advancedStartDatafeed(jobCreator: JobCreatorType) { export function aggFieldPairsCanBeCharted(afs: AggFieldPair[]) { return afs.some(a => a.agg.dslName === null) === false; } + +export function getJobCreatorTitle(jobCreator: JobCreatorType) { + switch (jobCreator.type) { + case JOB_TYPE.SINGLE_METRIC: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.singleMetric', { + defaultMessage: 'Single metric', + }); + case JOB_TYPE.MULTI_METRIC: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.multiMetric', { + defaultMessage: 'Multi metric', + }); + case JOB_TYPE.POPULATION: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.population', { + defaultMessage: 'Population', + }); + case JOB_TYPE.ADVANCED: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.advanced', { + defaultMessage: 'Advanced', + }); + case JOB_TYPE.CATEGORIZATION: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', { + defaultMessage: 'Categorization', + }); + default: + return ''; + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts index 3c1f767aeaf9cb..976e94b377ae8b 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_validator/job_validator.ts @@ -12,10 +12,11 @@ import { basicDatafeedValidation, } from '../../../../../../common/util/job_utils'; import { getNewJobLimits } from '../../../../services/ml_server_info'; -import { JobCreator, JobCreatorType } from '../job_creator'; +import { JobCreator, JobCreatorType, isCategorizationJobCreator } from '../job_creator'; import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; import { ExistingJobsAndGroups } from '../../../../services/job_service'; import { cardinalityValidator, CardinalityValidatorResult } from './validators'; +import { CATEGORY_EXAMPLES_ERROR_LIMIT } from '../../../../../../common/constants/new_job'; // delay start of validation to allow the user to make changes // e.g. if they are typing in a new value, try not to validate @@ -51,6 +52,10 @@ export interface BasicValidations { scrollSize: Validation; } +export interface AdvancedValidations { + categorizationFieldValid: Validation; +} + export class JobValidator { private _jobCreator: JobCreatorType; private _validationSummary: ValidationSummary; @@ -71,6 +76,9 @@ export class JobValidator { frequency: { valid: true }, scrollSize: { valid: true }, }; + private _advancedValidations: AdvancedValidations = { + categorizationFieldValid: { valid: true }, + }; private _validating: boolean = false; private _basicValidationResult$ = new ReplaySubject(2); @@ -141,6 +149,7 @@ export class JobValidator { this._lastDatafeedConfig = formattedDatafeedConfig; this._validateTimeout = setTimeout(() => { this._runBasicValidation(); + this._runAdvancedValidation(); this._jobCreatorSubject$.next(this._jobCreator); @@ -195,6 +204,13 @@ export class JobValidator { this._basicValidationResult$.next(this._basicValidations); } + private _runAdvancedValidation() { + if (isCategorizationJobCreator(this._jobCreator)) { + this._advancedValidations.categorizationFieldValid.valid = + this._jobCreator.categoryFieldValid > CATEGORY_EXAMPLES_ERROR_LIMIT; + } + } + private _isOverallBasicValid() { return Object.values(this._basicValidations).some(v => v.valid === false) === false; } @@ -246,4 +262,12 @@ export class JobValidator { public get validating(): boolean { return this._validating; } + + public get categorizationField() { + return this._advancedValidations.categorizationFieldValid.valid; + } + + public set categorizationField(valid: boolean) { + this._advancedValidations.categorizationFieldValid.valid = valid; + } } diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts new file mode 100644 index 00000000000000..16f127ae3d7283 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { Token } from '../../../../../../common/types/categories'; +import { CategorizationJobCreator } from '../job_creator'; +import { ml } from '../../../../services/ml_api_service'; +import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../common/constants/new_job'; + +export interface CategoryExample { + text: string; + tokens: Token[]; +} + +export class CategorizationExamplesLoader { + private _jobCreator: CategorizationJobCreator; + private _indexPatternTitle: IndexPatternTitle = ''; + private _timeFieldName: string = ''; + private _query: object = {}; + + constructor(jobCreator: CategorizationJobCreator, indexPattern: IndexPattern, query: object) { + this._jobCreator = jobCreator; + this._indexPatternTitle = indexPattern.title; + this._query = query; + + if (typeof indexPattern.timeFieldName === 'string') { + this._timeFieldName = indexPattern.timeFieldName; + } + } + + public async loadExamples() { + const analyzer = this._jobCreator.categorizationAnalyzer; + const categorizationFieldName = this._jobCreator.categorizationFieldName; + if (categorizationFieldName === null) { + return { valid: 0, examples: [] }; + } + + const start = Math.floor( + this._jobCreator.start + (this._jobCreator.end - this._jobCreator.start) / 2 + ); + const resp = await ml.jobs.categorizationFieldExamples( + this._indexPatternTitle, + this._query, + NUMBER_OF_CATEGORY_EXAMPLES, + categorizationFieldName, + this._timeFieldName, + start, + 0, + analyzer + ); + return resp; + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts index ef0b05f73fa31f..724c62f22e469f 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/results_loader/index.ts @@ -5,3 +5,4 @@ */ export { ResultsLoader, Results, ModelItem, Anomaly } from './results_loader'; +export { CategorizationExamplesLoader, CategoryExample } from './categorization_examples_loader'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx index 728229fc3091db..e519b86278ed83 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomaly_chart.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { Chart, Settings, TooltipType } from '@elastic/charts'; import { ModelItem, Anomaly } from '../../../../common/results_loader'; -import { Anomalies } from './anomalies'; +import { Anomalies } from '../common/anomalies'; import { ModelBounds } from './model_bounds'; import { Line } from './line'; import { Scatter } from './scatter'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomalies.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/anomalies.tsx similarity index 100% rename from x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/anomaly_chart/anomalies.tsx rename to x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/anomalies.tsx diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts index b1852cbb259c17..7dec882429dce7 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/common/settings.ts @@ -20,6 +20,7 @@ const themeName = IS_DARK_THEME ? darkTheme : lightTheme; export const LINE_COLOR = themeName.euiColorPrimary; export const MODEL_COLOR = themeName.euiColorPrimary; export const EVENT_RATE_COLOR = themeName.euiColorPrimary; +export const EVENT_RATE_COLOR_WITH_ANOMALIES = themeName.euiColorLightShade; export interface ChartSettings { width: string; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx index 5423e80a82f153..ddbeb3f0f5b04a 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/event_rate_chart/event_rate_chart.tsx @@ -8,24 +8,32 @@ import React, { FC } from 'react'; import { BarSeries, Chart, ScaleType, Settings, TooltipType } from '@elastic/charts'; import { Axes } from '../common/axes'; import { LineChartPoint } from '../../../../common/chart_loader'; -import { EVENT_RATE_COLOR } from '../common/settings'; +import { Anomaly } from '../../../../common/results_loader'; +import { EVENT_RATE_COLOR, EVENT_RATE_COLOR_WITH_ANOMALIES } from '../common/settings'; import { LoadingWrapper } from '../loading_wrapper'; +import { Anomalies } from '../common/anomalies'; interface Props { eventRateChartData: LineChartPoint[]; + anomalyData?: Anomaly[]; height: string; width: string; showAxis?: boolean; loading?: boolean; + fadeChart?: boolean; } export const EventRateChart: FC = ({ eventRateChartData, + anomalyData, height, width, showAxis, loading = false, + fadeChart, }) => { + const barColor = fadeChart ? EVENT_RATE_COLOR_WITH_ANOMALIES : EVENT_RATE_COLOR; + return (
= ({ {showAxis === true && } + = ({ xAccessor={'time'} yAccessors={['value']} data={eventRateChartData} - customSeriesColors={[EVENT_RATE_COLOR]} + customSeriesColors={[barColor]} /> diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx index 1087fdc8d0fceb..b8600489a4bd97 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/charts/loading_wrapper/loading_wrapper.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; interface Props { hasData: boolean; - height: string; + height?: string; loading?: boolean; } @@ -31,7 +31,7 @@ export const LoadingWrapper: FC = ({ hasData, loading = false, height, ch diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx new file mode 100644 index 00000000000000..a44cbf3d0c71ae --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/edit_categorization_analyzer_flyout.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useEffect, useState, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiTitle, + EuiFlyoutBody, + EuiSpacer, +} from '@elastic/eui'; +import { MLJobEditor } from '../../../../../jobs_list/components/ml_job_editor'; +import { isValidJson } from '../../../../../../../../common/util/validation_utils'; +import { JobCreatorContext } from '../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../common/job_creator'; +import { getNewJobDefaults } from '../../../../../../services/ml_server_info'; + +const EDITOR_HEIGHT = '800px'; + +export const EditCategorizationAnalyzerFlyout: FC = () => { + const { jobCreator: jc, jobCreatorUpdate } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + const [showJsonFlyout, setShowJsonFlyout] = useState(false); + const [saveable, setSaveable] = useState(false); + + const [categorizationAnalyzerString, setCategorizationAnalyzerString] = useState( + JSON.stringify(jobCreator.categorizationAnalyzer, null, 2) + ); + + useEffect(() => { + if (showJsonFlyout === true) { + setCategorizationAnalyzerString(JSON.stringify(jobCreator.categorizationAnalyzer, null, 2)); + } + }, [showJsonFlyout]); + + function toggleJsonFlyout() { + setSaveable(false); + setShowJsonFlyout(!showJsonFlyout); + } + + function onJSONChange(json: string) { + setCategorizationAnalyzerString(json); + const valid = isValidJson(json); + setSaveable(valid); + } + + function onSave() { + jobCreator.categorizationAnalyzer = JSON.parse(categorizationAnalyzerString); + jobCreatorUpdate(); + setShowJsonFlyout(false); + } + + function onUseDefault() { + const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults(); + const analyzerString = JSON.stringify(anomalyDetectors.categorization_analyzer!, null, 2); + onJSONChange(analyzerString); + } + + return ( + + + + {showJsonFlyout === true && ( + setShowJsonFlyout(false)} hideCloseButton size="m"> + + + + + + + setShowJsonFlyout(false)} + flush="left" + > + + + + + + + + + + + + + + + + + + )} + + ); +}; + +const FlyoutButton: FC<{ onClick(): void }> = ({ onClick }) => { + return ( + + + + ); +}; + +const Contents: FC<{ + title: string; + value: string; + onChange(s: string): void; +}> = ({ title, value, onChange }) => { + return ( + + +
{title}
+
+ + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts new file mode 100644 index 00000000000000..5bc89a695b6c84 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/common/edit_categorization_analyzer_flyout/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { EditCategorizationAnalyzerFlyout } from './edit_categorization_analyzer_flyout'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx index 64f9f450ae08d2..a034bdcc2900f8 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -10,16 +10,29 @@ import { EuiSpacer, EuiSwitch } from '@elastic/eui'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; import { MMLCallout } from '../mml_callout'; +import { ML_JOB_AGGREGATION } from '../../../../../../../../../../../common/constants/aggregation_types'; +import { isCategorizationJobCreator } from '../../../../../../../common/job_creator'; export const ModelPlotSwitch: FC = () => { - const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); const [modelPlotEnabled, setModelPlotEnabled] = useState(jobCreator.modelPlot); + const [enabled, setEnabled] = useState(false); useEffect(() => { jobCreator.modelPlot = modelPlotEnabled; jobCreatorUpdate(); }, [modelPlotEnabled]); + useEffect(() => { + const aggs = [ML_JOB_AGGREGATION.RARE]; + // disable model plot switch if the wizard is creating a categorization job + // and a rare detector is being used. + const isRareCategoryJob = + isCategorizationJobCreator(jobCreator) && + jobCreator.aggregations.some(agg => aggs.includes(agg.id)); + setEnabled(isRareCategoryJob === false); + }, [jobCreatorUpdated]); + function toggleModelPlot() { setModelPlotEnabled(!modelPlotEnabled); } @@ -29,6 +42,7 @@ export const ModelPlotSwitch: FC = () => { = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx index dfe9272984b814..216561bac2c626 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx @@ -14,9 +14,10 @@ import { BucketSpanEstimator } from '../bucket_span_estimator'; interface Props { setIsValid: (proceed: boolean) => void; + hideEstimateButton?: boolean; } -export const BucketSpan: FC = ({ setIsValid }) => { +export const BucketSpan: FC = ({ setIsValid, hideEstimateButton = false }) => { const { jobCreator, jobCreatorUpdate, @@ -56,9 +57,11 @@ export const BucketSpan: FC = ({ setIsValid }) => { disabled={estimating} />
- - - + {hideEstimateButton === false && ( + + + + )}
); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx new file mode 100644 index 00000000000000..96ac1c3f0e325c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/categorization_detector.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { ML_JOB_AGGREGATION } from '../../../../../../../../../common/constants/aggregation_types'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { CountCard, RareCard } from './detector_cards'; + +export const CategorizationDetector: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + const [categorizationDetectorType, setCategorizationDetectorType] = useState( + jobCreator.selectedDetectorType + ); + + useEffect(() => { + if (categorizationDetectorType !== jobCreator.selectedDetectorType) { + jobCreator.setDetectorType(categorizationDetectorType); + jobCreatorUpdate(); + } + }, [categorizationDetectorType]); + + useEffect(() => { + setCategorizationDetectorType(jobCreator.selectedDetectorType); + }, [jobCreatorUpdated]); + + function onCountSelection() { + setCategorizationDetectorType(ML_JOB_AGGREGATION.COUNT); + } + function onRareSelection() { + setCategorizationDetectorType(ML_JOB_AGGREGATION.RARE); + } + + return ( + <> + +

+ +

+
+ + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.tsx new file mode 100644 index 00000000000000..68d5fc24a96e3c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/detector_cards.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexItem, EuiCard } from '@elastic/eui'; + +interface CardProps { + onClick: () => void; + isSelected: boolean; +} + +export const CountCard: FC = ({ onClick, isSelected }) => ( + + + + + } + selectable={{ onClick, isSelected }} + /> + +); + +export const RareCard: FC = ({ onClick, isSelected }) => ( + + + + + } + selectable={{ onClick, isSelected }} + /> + +); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts new file mode 100644 index 00000000000000..6a13d86d0db9a3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_detector/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { CategorizationDetector } from './categorization_detector'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx index f9edf79364c97d..7f7659d8bb6fd6 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx @@ -10,23 +10,26 @@ import { CategorizationFieldSelect } from './categorization_field_select'; import { JobCreatorContext } from '../../../job_creator_context'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { - MultiMetricJobCreator, - PopulationJobCreator, AdvancedJobCreator, + CategorizationJobCreator, + isCategorizationJobCreator, } from '../../../../../common/job_creator'; import { Description } from './description'; export const CategorizationField: FC = () => { const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator | AdvancedJobCreator; + const jobCreator = jc as AdvancedJobCreator | CategorizationJobCreator; const { catFields } = newJobCapsService; const [categorizationFieldName, setCategorizationFieldName] = useState( jobCreator.categorizationFieldName ); + const isCategorizationJob = isCategorizationJobCreator(jobCreator); useEffect(() => { - jobCreator.categorizationFieldName = categorizationFieldName; - jobCreatorUpdate(); + if (jobCreator.categorizationFieldName !== categorizationFieldName) { + jobCreator.categorizationFieldName = categorizationFieldName; + jobCreatorUpdate(); + } }, [categorizationFieldName]); useEffect(() => { @@ -34,7 +37,7 @@ export const CategorizationField: FC = () => { }, [jobCreatorUpdated]); return ( - + { +interface Props { + isOptional: boolean; +} +export const Description: FC = memo(({ children, isOptional }) => { const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.categorizationField.title', { defaultMessage: 'Categorization field', }); @@ -18,10 +21,19 @@ export const Description: FC = memo(({ children }) => { idAria="description" title={

{title}

} description={ - + <> + {isOptional ? ( + + ) : ( + + )} + } > diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx new file mode 100644 index 00000000000000..5017b0c3239e84 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/categorization_view.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { CategorizationDetectors } from './metric_selection'; +import { CategorizationDetectorsSummary } from './metric_selection_summary'; +import { CategorizationSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed?: (proceed: boolean) => void; +} + +export const CategorizationView: FC = ({ isActive, setCanProceed }) => { + const [categoryFieldValid, setCategoryFieldValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(categoryFieldValid && settingsValid); + } + }, [categoryFieldValid, settingsValid]); + + return isActive === false ? ( + + ) : ( + <> + + {categoryFieldValid && ( + <> + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx new file mode 100644 index 00000000000000..04934d2dc9a36d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/examples_valid_callout.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiCallOut, EuiSpacer, EuiCallOutProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { CategorizationAnalyzer } from '../../../../../../../services/ml_server_info'; +import { EditCategorizationAnalyzerFlyout } from '../../../common/edit_categorization_analyzer_flyout'; +import { + NUMBER_OF_CATEGORY_EXAMPLES, + CATEGORY_EXAMPLES_MULTIPLIER, + CATEGORY_EXAMPLES_ERROR_LIMIT, + CATEGORY_EXAMPLES_WARNING_LIMIT, +} from '../../../../../../../../../common/constants/new_job'; + +type CategorizationAnalyzerType = CategorizationAnalyzer | null; + +interface Props { + examplesValid: number; + categorizationAnalyzer: CategorizationAnalyzerType; +} + +export const ExamplesValidCallout: FC = ({ examplesValid, categorizationAnalyzer }) => { + const percentageText = ; + const analyzerUsed = ; + + let color: EuiCallOutProps['color'] = 'success'; + let title = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.valid', + { + defaultMessage: 'Selected category field is valid', + } + ); + + if (examplesValid < CATEGORY_EXAMPLES_ERROR_LIMIT) { + color = 'danger'; + title = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.invalid', + { + defaultMessage: 'Selected category field is invalid', + } + ); + } else if (examplesValid < CATEGORY_EXAMPLES_WARNING_LIMIT) { + color = 'warning'; + title = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldCalloutTitle.possiblyInvalid', + { + defaultMessage: 'Selected category field is possibly invalid', + } + ); + } + + return ( + + {percentageText} + + {analyzerUsed} + + ); +}; + +const PercentageText: FC<{ examplesValid: number }> = ({ examplesValid }) => ( +
+ +
+); + +const AnalyzerUsed: FC<{ categorizationAnalyzer: CategorizationAnalyzerType }> = ({ + categorizationAnalyzer, +}) => { + let analyzer = ''; + if (typeof categorizationAnalyzer === null) { + return null; + } + + if (typeof categorizationAnalyzer === 'string') { + analyzer = categorizationAnalyzer; + } else { + if (categorizationAnalyzer?.tokenizer !== undefined) { + analyzer = categorizationAnalyzer?.tokenizer!; + } else if (categorizationAnalyzer?.analyzer !== undefined) { + analyzer = categorizationAnalyzer?.analyzer!; + } + } + + return ( + <> +
+ +
+
+ +
+ + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx new file mode 100644 index 00000000000000..7f9b2e43b90050 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/field_examples.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiText } from '@elastic/eui'; +import { CategoryExample } from '../../../../../common/results_loader'; + +interface Props { + fieldExamples: CategoryExample[] | null; +} + +const TOKEN_HIGHLIGHT_COLOR = '#b0ccf7'; + +export const FieldExamples: FC = ({ fieldExamples }) => { + if (fieldExamples === null || fieldExamples.length === 0) { + return null; + } + + const columns = [ + { + field: 'example', + name: i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.categorizationFieldExamples.title', + { + defaultMessage: 'Examples', + } + ), + render: (example: any) => ( + + {example} + + ), + }, + ]; + const items = fieldExamples.map((example, i) => { + const txt = []; + let tokenCounter = 0; + let buffer = ''; + let charCount = 0; + while (charCount < example.text.length) { + const token = example.tokens[tokenCounter]; + if (token && charCount === token.start_offset) { + txt.push(buffer); + buffer = ''; + txt.push({token.token}); + charCount += token.end_offset - token.start_offset; + tokenCounter++; + } else { + buffer += example.text[charCount]; + charCount++; + } + } + txt.push(buffer); + return { example: txt }; + }); + return ; +}; + +const Token: FC = ({ children }) => ( + {children} +); diff --git a/x-pack/test/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/index.ts similarity index 54% rename from x-pack/test/typings/encode_uri_query.d.ts rename to x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/index.ts index e1ab5f4a70abfe..f61dfd88a37bb6 100644 --- a/x-pack/test/typings/encode_uri_query.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { CategorizationView } from './categorization_view'; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx new file mode 100644 index 00000000000000..fda0066f9cd379 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { CategorizationField } from '../categorization_field'; +import { CategorizationDetector } from '../categorization_detector'; +import { FieldExamples } from './field_examples'; +import { ExamplesValidCallout } from './examples_valid_callout'; +import { CategoryExample } from '../../../../../common/results_loader'; +import { LoadingWrapper } from '../../../charts/loading_wrapper'; + +interface Props { + setIsValid: (na: boolean) => void; +} + +export const CategorizationDetectors: FC = ({ setIsValid }) => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + + const [loadingData, setLoadingData] = useState(false); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [categorizationAnalyzerString, setCategorizationAnalyzerString] = useState( + JSON.stringify(jobCreator.categorizationAnalyzer) + ); + const [fieldExamples, setFieldExamples] = useState(null); + const [examplesValid, setExamplesValid] = useState(0); + + const [categorizationFieldName, setCategorizationFieldName] = useState( + jobCreator.categorizationFieldName + ); + + useEffect(() => { + if (jobCreator.categorizationFieldName !== categorizationFieldName) { + jobCreator.categorizationFieldName = categorizationFieldName; + jobCreatorUpdate(); + } + loadFieldExamples(); + }, [categorizationFieldName]); + + useEffect(() => { + let updateExamples = false; + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + updateExamples = true; + } + const tempCategorizationAnalyzerString = JSON.stringify(jobCreator.categorizationAnalyzer); + if (tempCategorizationAnalyzerString !== categorizationAnalyzerString) { + setCategorizationAnalyzerString(tempCategorizationAnalyzerString); + updateExamples = true; + } + + if (updateExamples) { + loadFieldExamples(); + } + if (jobCreator.categorizationFieldName !== categorizationFieldName) { + setCategorizationFieldName(jobCreator.categorizationFieldName); + } + }, [jobCreatorUpdated]); + + async function loadFieldExamples() { + if (categorizationFieldName !== null) { + setLoadingData(true); + const { valid, examples } = await jobCreator.loadCategorizationFieldExamples(); + setFieldExamples(examples); + setExamplesValid(valid); + setLoadingData(false); + } else { + setFieldExamples(null); + setExamplesValid(0); + } + setIsValid(categorizationFieldName !== null); + } + + useEffect(() => { + jobCreatorUpdate(); + }, [examplesValid]); + + return ( + <> + + + + {loadingData === true && ( + +
+ + )} + {fieldExamples !== null && loadingData === false && ( + <> + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx new file mode 100644 index 00000000000000..768d8c394fb8f4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/metric_selection_summary.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { Results, Anomaly } from '../../../../../common/results_loader'; +import { LineChartPoint } from '../../../../../common/chart_loader'; +import { EventRateChart } from '../../../charts/event_rate_chart'; +import { TopCategories } from './top_categories'; + +const DTR_IDX = 0; + +export const CategorizationDetectorsSummary: FC = () => { + const { jobCreator: jc, chartLoader, resultsLoader, chartInterval } = useContext( + JobCreatorContext + ); + const jobCreator = jc as CategorizationJobCreator; + + const [loadingData, setLoadingData] = useState(false); + const [anomalyData, setAnomalyData] = useState([]); + const [eventRateChartData, setEventRateChartData] = useState([]); + const [jobIsRunning, setJobIsRunning] = useState(false); + + function setResultsWrapper(results: Results) { + const anomalies = results.anomalies[DTR_IDX]; + if (anomalies !== undefined) { + setAnomalyData(anomalies); + } + } + + function watchProgress(progress: number) { + setJobIsRunning(progress > 0); + } + + useEffect(() => { + // subscribe to progress and results + const resultsSubscription = resultsLoader.subscribeToResults(setResultsWrapper); + jobCreator.subscribeToProgress(watchProgress); + loadChart(); + return () => { + resultsSubscription.unsubscribe(); + }; + }, []); + + async function loadChart() { + setLoadingData(true); + try { + const resp = await chartLoader.loadEventRateChart( + jobCreator.start, + jobCreator.end, + chartInterval.getInterval().asMilliseconds() + ); + setEventRateChartData(resp); + } catch (error) { + setEventRateChartData([]); + } + setLoadingData(false); + } + + return ( + <> + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.tsx new file mode 100644 index 00000000000000..55db3d495707d4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/settings.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { BucketSpan } from '../bucket_span'; + +interface Props { + setIsValid: (proceed: boolean) => void; +} + +export const CategorizationSettings: FC = ({ setIsValid }) => { + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx new file mode 100644 index 00000000000000..3bade07250b464 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_view/top_categories.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; +import { EuiBasicTable, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { CategorizationJobCreator } from '../../../../../common/job_creator'; +import { Results } from '../../../../../common/results_loader'; +import { ml } from '../../../../../../../services/ml_api_service'; +import { NUMBER_OF_CATEGORY_EXAMPLES } from '../../../../../../../../../common/constants/new_job'; + +export const TopCategories: FC = () => { + const { jobCreator: jc, resultsLoader } = useContext(JobCreatorContext); + const jobCreator = jc as CategorizationJobCreator; + + const [tableRow, setTableRow] = useState>([]); + const [totalCategories, setTotalCategories] = useState(0); + + function setResultsWrapper(results: Results) { + loadTopCats(); + } + + async function loadTopCats() { + const results = await ml.jobs.topCategories(jobCreator.jobId, NUMBER_OF_CATEGORY_EXAMPLES); + setTableRow( + results.categories.map(c => ({ + count: c.count, + example: c.category.examples?.length ? c.category.examples[0] : '', + })) + ); + setTotalCategories(results.total); + } + + useEffect(() => { + // subscribe to result updates + const resultsSubscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + resultsSubscription.unsubscribe(); + }; + }, []); + + const columns = [ + // only include counts if model plot is enabled + ...(jobCreator.modelPlot + ? [ + { + field: 'count', + name: 'count', + width: '100px', + render: (count: any) => ( + + {count} + + ), + }, + ] + : []), + { + field: 'example', + name: 'Example', + render: (example: any) => ( + + {example} + + ), + }, + ]; + + return ( + <> + {totalCategories > 0 && ( + <> +
+ +
+ + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx index 5e800de755f26c..b28a9d3da81dcb 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { JobCreatorContext } from '../../../job_creator_context'; import { BucketSpan } from '../bucket_span'; import { SplitFieldSelector } from '../split_field'; import { Influencers } from '../influencers'; @@ -18,19 +17,6 @@ interface Props { } export const MultiMetricSettings: FC = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx index 46cdf44ce0f7da..b9de755e6c9469 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/settings.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { JobCreatorContext } from '../../../job_creator_context'; import { BucketSpan } from '../bucket_span'; import { Influencers } from '../influencers'; @@ -16,19 +15,6 @@ interface Props { } export const PopulationSettings: FC = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); - return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx index c750235051a6fa..f8e7275cf15bb3 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/settings.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC, useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; @@ -19,18 +19,7 @@ interface Props { } export const SingleMetricSettings: FC = ({ setIsValid }) => { - const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); - - useEffect(() => { - jobCreator.bucketSpan = bucketSpan; - jobCreatorUpdate(); - setIsValid(bucketSpan !== ''); - }, [bucketSpan]); - - useEffect(() => { - setBucketSpan(jobCreator.bucketSpan); - }, [jobCreatorUpdated]); + const { jobCreator } = useContext(JobCreatorContext); const convertToMultiMetric = () => { convertToMultiMetricJob(jobCreator); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx index 795dfc30f954a4..bfec49678bc34b 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/pick_fields.tsx @@ -15,6 +15,7 @@ import { SingleMetricView } from './components/single_metric_view'; import { MultiMetricView } from './components/multi_metric_view'; import { PopulationView } from './components/population_view'; import { AdvancedView } from './components/advanced_view'; +import { CategorizationView } from './components/categorization_view'; import { JsonEditorFlyout, EDITOR_MODE } from '../common/json_editor_flyout'; import { DatafeedPreviewFlyout } from '../common/datafeed_preview_flyout'; @@ -30,7 +31,9 @@ export const PickFieldsStep: FC = ({ setCurrentStep, isCurrentStep }) (jobCreator.type === JOB_TYPE.ADVANCED && jobValidator.modelMemoryLimit.valid)) && jobValidator.bucketSpan.valid && jobValidator.duplicateDetectors.valid && - jobValidator.validating === false; + jobValidator.validating === false && + (jobCreator.type !== JOB_TYPE.CATEGORIZATION || + (jobCreator.type === JOB_TYPE.CATEGORIZATION && jobValidator.categorizationField)); setNextActive(active); }, [jobValidatorUpdated]); @@ -50,6 +53,9 @@ export const PickFieldsStep: FC = ({ setCurrentStep, isCurrentStep }) {jobType === JOB_TYPE.ADVANCED && ( )} + {jobType === JOB_TYPE.CATEGORIZATION && ( + + )} setCurrentStep( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx index f72ff6cf985e56..564228604d71ec 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/detector_chart/detector_chart.tsx @@ -11,6 +11,7 @@ import { SingleMetricView } from '../../../pick_fields_step/components/single_me import { MultiMetricView } from '../../../pick_fields_step/components/multi_metric_view'; import { PopulationView } from '../../../pick_fields_step/components/population_view'; import { AdvancedView } from '../../../pick_fields_step/components/advanced_view'; +import { CategorizationView } from '../../../pick_fields_step/components/categorization_view'; export const DetectorChart: FC = () => { const { jobCreator } = useContext(JobCreatorContext); @@ -21,6 +22,7 @@ export const DetectorChart: FC = () => { {jobCreator.type === JOB_TYPE.MULTI_METRIC && } {jobCreator.type === JOB_TYPE.POPULATION && } {jobCreator.type === JOB_TYPE.ADVANCED && } + {jobCreator.type === JOB_TYPE.CATEGORIZATION && } ); }; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx index b0c5758fdd2e7c..92429cf907592d 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/wizard_nav/wizard_nav.tsx @@ -8,7 +8,13 @@ import React, { FC, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, +} from '@elastic/eui'; interface StepsNavProps { previousActive?: boolean; @@ -44,20 +50,23 @@ export const WizardNav: FC = ({ ); export const PreviousButton: FC = ({ previous, previousActive = true }) => ( - - + ); export const NextButton: FC = ({ next, nextActive = true }) => ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts index 8500279e742b78..51fc226751ae2a 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts @@ -32,15 +32,24 @@ function getWizardUrlFromCloningJob(job: CombinedJob) { const created = job?.custom_settings?.created_by; let page = ''; - if (created === CREATED_BY_LABEL.SINGLE_METRIC) { - page = JOB_TYPE.SINGLE_METRIC; - } else if (created === CREATED_BY_LABEL.MULTI_METRIC) { - page = JOB_TYPE.MULTI_METRIC; - } else if (created === CREATED_BY_LABEL.POPULATION) { - page = JOB_TYPE.POPULATION; - } else { - page = JOB_TYPE.ADVANCED; + switch (created) { + case CREATED_BY_LABEL.SINGLE_METRIC: + page = JOB_TYPE.SINGLE_METRIC; + break; + case CREATED_BY_LABEL.MULTI_METRIC: + page = JOB_TYPE.MULTI_METRIC; + break; + case CREATED_BY_LABEL.POPULATION: + page = JOB_TYPE.POPULATION; + break; + case CREATED_BY_LABEL.CATEGORIZATION: + page = JOB_TYPE.CATEGORIZATION; + break; + default: + page = JOB_TYPE.ADVANCED; + break; } + const indexPatternId = getIndexPatternIdFromName(job.datafeed_config.indices[0]); return `jobs/new_job/${page}?index=${indexPatternId}&_g=()`; diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index dbae1948cbe0f6..9a44d561c2d944 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -151,6 +151,22 @@ export const Page: FC = () => { }), id: 'mlJobTypeLinkAdvancedJob', }, + { + href: getUrl('#jobs/new_job/categorization'), + icon: { + type: 'createAdvancedJob', + ariaLabel: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationAriaLabel', { + defaultMessage: 'Categorization job', + }), + }, + title: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationTitle', { + defaultMessage: 'Categorization', + }), + description: i18n.translate('xpack.ml.newJob.wizard.jobType.categorizationDescription', { + defaultMessage: 'Group log messages into categories and detect anomalies within them.', + }), + id: 'mlJobTypeLinkCategorizationJob', + }, ]; return ( diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index 79f98c1170ff8d..ece43e00f2eb1a 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -5,13 +5,26 @@ */ import React, { FC, useEffect, Fragment } from 'react'; - -import { EuiPage, EuiPageBody, EuiPageContentBody } from '@elastic/eui'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiTitle, + EuiPageContentBody, +} from '@elastic/eui'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { Wizard } from './wizard'; import { WIZARD_STEPS } from '../components/step_types'; -import { jobCreatorFactory, isAdvancedJobCreator } from '../../common/job_creator'; +import { getJobCreatorTitle } from '../../common/job_creator/util/general'; +import { + jobCreatorFactory, + isAdvancedJobCreator, + isCategorizationJobCreator, +} from '../../common/job_creator'; import { JOB_TYPE, DEFAULT_MODEL_MEMORY_LIMIT, @@ -25,6 +38,9 @@ import { getTimeFilterRange } from '../../../../components/full_time_range_selec import { TimeBuckets } from '../../../../util/time_buckets'; import { ExistingJobsAndGroups, mlJobService } from '../../../../services/job_service'; import { expandCombinedJobConfig } from '../../common/job_creator/configs'; +import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; +import { EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields'; +import { getNewJobDefaults } from '../../../../services/ml_server_info'; const PAGE_WIDTH = 1200; // document.querySelector('.single-metric-job-container').width(); const BAR_TARGET = PAGE_WIDTH > 2000 ? 1000 : PAGE_WIDTH / 2; @@ -37,7 +53,6 @@ export interface PageProps { export const Page: FC = ({ existingJobsAndGroups, jobType }) => { const kibanaContext = useKibanaContext(); - const jobCreator = jobCreatorFactory(jobType)( kibanaContext.currentIndexPattern, kibanaContext.currentSavedSearch, @@ -58,6 +73,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // cloning a job const clonedJob = mlJobService.cloneJob(mlJobService.tempJobCloningObjects.job); const { job, datafeed } = expandCombinedJobConfig(clonedJob); + initCategorizationSettings(); jobCreator.cloneFromExistingJob(job, datafeed); // if we're not skipping the time range, this is a standard job clone, so wipe the jobId @@ -95,7 +111,11 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // creating a new job jobCreator.bucketSpan = DEFAULT_BUCKET_SPAN; - if (jobCreator.type !== JOB_TYPE.POPULATION && jobCreator.type !== JOB_TYPE.ADVANCED) { + if ( + jobCreator.type !== JOB_TYPE.POPULATION && + jobCreator.type !== JOB_TYPE.ADVANCED && + jobCreator.type !== JOB_TYPE.CATEGORIZATION + ) { // for all other than population or advanced, use 10MB jobCreator.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } @@ -112,6 +132,13 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // auto set the time range if creating a new advanced job autoSetTimeRange = isAdvancedJobCreator(jobCreator); + initCategorizationSettings(); + if (isCategorizationJobCreator(jobCreator)) { + const { catFields } = newJobCapsService; + if (catFields.length === 1) { + jobCreator.categorizationFieldName = catFields[0].name; + } + } } if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { @@ -129,6 +156,20 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { } } + function initCategorizationSettings() { + if (isCategorizationJobCreator(jobCreator)) { + // categorization job will always use a count agg, so give it + // to the job creator now + const count = newJobCapsService.getAggById('count'); + const rare = newJobCapsService.getAggById('rare'); + const eventRate = newJobCapsService.getFieldById(EVENT_RATE_FIELD_ID); + jobCreator.setDefaultDetectorProperties(count, rare, eventRate); + + const { anomaly_detectors: anomalyDetectors } = getNewJobDefaults(); + jobCreator.categorizationAnalyzer = anomalyDetectors.categorization_analyzer!; + } + } + const chartInterval = new TimeBuckets(); chartInterval.setBarTarget(BAR_TARGET); chartInterval.setMaxBars(MAX_BARS); @@ -149,21 +190,39 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { }; }); + const jobCreatorTitle = getJobCreatorTitle(jobCreator); + return ( - - - + + + + +

+ + : {jobCreatorTitle} +

+
+
+
+ + + + +
diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx index 8e81c05092c98b..df21e70a68ed97 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx @@ -143,8 +143,8 @@ export const WizardSteps: FC = ({ currentStep, setCurrentStep }) => { const Title: FC<{ 'data-test-subj': string }> = ({ 'data-test-subj': dataTestSubj, children }) => { return ( - -

{children}

+ +

{children}

diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index d1e81c1c344de0..0f19451b232632 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -4,12 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - IndexPattern, - esQuery, - Query, - esKuery, -} from '../../../../../../../../../src/plugins/data/public'; +import { esQuery, Query, esKuery } from '../../../../../../../../../src/plugins/data/public'; +import { IIndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns'; import { KibanaConfigTypeFix } from '../../../contexts/kibana'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search'; import { SavedSearchSavedObject } from '../../../../../common/types/kibana'; @@ -19,7 +15,7 @@ import { getQueryFromSavedSearch } from '../../../util/index_utils'; export function createSearchItems( kibanaConfig: KibanaConfigTypeFix, - indexPattern: IndexPattern, + indexPattern: IIndexPattern, savedSearch: SavedSearchSavedObject | null ) { // query is only used by the data visualizer as it needs diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx index ea1baefdce0d1b..99c0511cd09ce7 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/new_job/wizard.tsx @@ -72,6 +72,16 @@ const advancedBreadcrumbs = [ }, ]; +const categorizationBreadcrumbs = [ + ...baseBreadcrumbs, + { + text: i18n.translate('xpack.ml.jobsBreadcrumbs.categorizationLabel', { + defaultMessage: 'Categorization', + }), + href: '', + }, +]; + export const singleMetricRoute: MlRoute = { path: '/jobs/new_job/single_metric', render: (props, config, deps) => ( @@ -104,6 +114,14 @@ export const advancedRoute: MlRoute = { breadcrumbs: advancedBreadcrumbs, }; +export const categorizationRoute: MlRoute = { + path: '/jobs/new_job/categorization', + render: (props, config, deps) => ( + + ), + breadcrumbs: categorizationBreadcrumbs, +}; + const PageWrapper: FC = ({ location, config, jobType, deps }) => { const { index, savedSearchId } = queryString.parse(location.search); const { context, results } = useResolver(index, savedSearchId, config, { diff --git a/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts b/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts index f74260c06567e0..3716b9715bb5bf 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts +++ b/x-pack/legacy/plugins/ml/public/application/routing/use_resolver.ts @@ -60,8 +60,6 @@ export const useResolver = ( } } catch (error) { // quietly fail. Let the resolvers handle the redirection if any fail to resolve - // eslint-disable-next-line no-console - console.error('ML page loading resolver', error); } })(); }, []); diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts index 58692754d95d70..2ad2a148f05d1a 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts @@ -7,6 +7,7 @@ import { Observable } from 'rxjs'; import { Annotation } from '../../../../common/types/annotations'; import { AggFieldNamePair } from '../../../../common/types/fields'; +import { Category } from '../../../../common/types/categories'; import { ExistingJobsAndGroups } from '../job_service'; import { PrivilegesResponse } from '../../../../common/types/privileges'; import { MlSummaryJobs } from '../../../../common/types/jobs'; @@ -107,7 +108,7 @@ declare interface Ml { checkManageMLPrivileges(): Promise; getJobStats(obj: object): Promise; getDatafeedStats(obj: object): Promise; - esSearch(obj: object): any; + esSearch(obj: object): Promise; esSearch$(obj: object): Observable; getIndices(): Promise; dataRecognizerModuleJobsExist(obj: { moduleId: string }): Promise; @@ -171,6 +172,20 @@ declare interface Ml { start: number, end: number ): Promise<{ progress: number; isRunning: boolean; isJobClosed: boolean }>; + categorizationFieldExamples( + indexPatternTitle: string, + query: object, + size: number, + field: string, + timeField: string | undefined, + start: number, + end: number, + analyzer: any + ): Promise<{ valid: number; examples: any[] }>; + topCategories( + jobId: string, + count: number + ): Promise<{ total: number; categories: Array<{ count?: number; category: Category }> }>; }; estimateBucketSpan(data: BucketSpanEstimatorData): Promise; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js index 4bec070b2cfdf2..05d98dc1a1e644 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/jobs.js @@ -206,4 +206,41 @@ export const jobs = { }, }); }, + + categorizationFieldExamples( + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer + ) { + return http({ + url: `${basePath}/jobs/categorization_field_examples`, + method: 'POST', + data: { + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer, + }, + }); + }, + + topCategories(jobId, count) { + return http({ + url: `${basePath}/jobs/top_categories`, + method: 'POST', + data: { + jobId, + count, + }, + }); + }, }; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts index 95d670eda8a4f1..6bf5a7b0c97433 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_server_info.ts @@ -11,10 +11,18 @@ export interface MlServerDefaults { categorization_examples_limit?: number; model_memory_limit?: string; model_snapshot_retention_days?: number; + categorization_analyzer?: CategorizationAnalyzer; }; datafeeds: { scroll_size?: number }; } +export interface CategorizationAnalyzer { + char_filter?: any[]; + tokenizer?: string; + filter?: any[]; + analyzer?: string; +} + export interface MlServerLimits { max_model_memory_limit?: string; } diff --git a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts index d78c9298c60736..051973d35d8deb 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -15,7 +15,6 @@ import { import { ES_FIELD_TYPES, IIndexPattern, - IndexPattern, IndexPatternsContract, } from '../../../../../../../src/plugins/data/public'; import { ml } from './ml_api_service'; @@ -31,7 +30,7 @@ export function loadNewJobCapabilities( return new Promise(async (resolve, reject) => { if (indexPatternId !== undefined) { // index pattern is being used - const indexPattern: IndexPattern = await indexPatterns.get(indexPatternId); + const indexPattern: IIndexPattern = await indexPatterns.get(indexPatternId); await newJobCapsService.initializeFromIndexPattern(indexPattern); resolve(newJobCapsService.newJobCaps); } else if (savedSearchId !== undefined) { diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 9db6f8f0a1c35b..4d10d73bcc0489 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -1523,12 +1523,12 @@ const TimeseriesChartIntl = injectI18n( } else { tooltipData.push({ name: intl.formatMessage({ - id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel', - defaultMessage: 'value', + id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.actualLabel', + defaultMessage: 'actual', }), - value: formatValue(marker.value, marker.function, fieldFormat), + value: formatValue(marker.actual, marker.function, fieldFormat), seriesKey, - yAccessor: 'value', + yAccessor: 'actual', }); tooltipData.push({ name: intl.formatMessage({ diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 202448340f526d..0ab10c4fe69cd9 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1394,156 +1394,158 @@ export class TimeSeriesExplorer extends React.Component { jobs.length > 0 && (fullRefresh === false || loading === false) && hasResults === true && ( - - {/* Make sure ChartTooltip is inside this plain wrapping element so positioning can be infered correctly. */} +
+ {/* Make sure ChartTooltip is inside this plain wrapping element without padding so positioning can be infered correctly. */} - - {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { - defaultMessage: 'Single time series analysis of {functionLabel}', - values: { functionLabel: chartDetails.functionLabel }, - })} - -   - {chartDetails.entityData.count === 1 && ( - - {chartDetails.entityData.entities.length > 0 && '('} - {chartDetails.entityData.entities - .map(entity => { - return `${entity.fieldName}: ${entity.fieldValue}`; - }) - .join(', ')} - {chartDetails.entityData.entities.length > 0 && ')'} - - )} - {chartDetails.entityData.count !== 1 && ( - - {chartDetails.entityData.entities.map((countData, i) => { - return ( - - {i18n.translate( - 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', - { - defaultMessage: - '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', - values: { - openBrace: i === 0 ? '(' : '', - closeBrace: - i === chartDetails.entityData.entities.length - 1 ? ')' : '', - cardinalityValue: - countData.cardinality === 0 - ? allValuesLabel - : countData.cardinality, - cardinality: countData.cardinality, - fieldName: countData.fieldName, - }, - } - )} - {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} - - ); + + + {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel: chartDetails.functionLabel }, })} - )} - - {showModelBoundsCheckbox && ( - - + {chartDetails.entityData.entities.length > 0 && '('} + {chartDetails.entityData.entities + .map(entity => { + return `${entity.fieldName}: ${entity.fieldValue}`; + }) + .join(', ')} + {chartDetails.entityData.entities.length > 0 && ')'} + + )} + {chartDetails.entityData.count !== 1 && ( + + {chartDetails.entityData.entities.map((countData, i) => { + return ( + + {i18n.translate( + 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', + { + defaultMessage: + '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', + values: { + openBrace: i === 0 ? '(' : '', + closeBrace: + i === chartDetails.entityData.entities.length - 1 ? ')' : '', + cardinalityValue: + countData.cardinality === 0 + ? allValuesLabel + : countData.cardinality, + cardinality: countData.cardinality, + fieldName: countData.fieldName, + }, + } + )} + {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} + + ); + })} + + )} + + {showModelBoundsCheckbox && ( + + + + )} + + {showAnnotationsCheckbox && ( + + + + )} + + {showForecastCheckbox && ( + + + + )} + +
+ +
+ {showAnnotations && focusAnnotationData.length > 0 && ( +
+ + {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { + defaultMessage: 'Annotations', })} - checked={showModelBounds} - onChange={this.toggleShowModelBoundsHandler} + + - + +
)} - - {showAnnotationsCheckbox && ( - - + + {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { + defaultMessage: 'Anomalies', + })} + + + + + > + + - )} - - {showForecastCheckbox && ( - - + + > + + - )} - -
- -
- {showAnnotations && focusAnnotationData.length > 0 && ( -
- - {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { - defaultMessage: 'Annotations', - })} - - - -
- )} - - - {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { - defaultMessage: 'Anomalies', - })} - - - - - - - - - - - - - - - + + + +
)} {arePartitioningFieldsProvided && jobs.length > 0 && ( diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts index a7afee237dba9c..8cdaa192fcbc9b 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -345,6 +345,98 @@ describe('ML - custom URL utils', () => { ); }); + test('returns expected URL for APM', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:"$trace.id$" and transaction.name:"$transaction.name$"&_g=()', + }; + + const testRecords = { + job_id: 'abnormal_trace_durations_nodejs', + result_type: 'record', + probability: 0.025597710862701226, + multi_bucket_impact: 5, + record_score: 13.124152090331723, + initial_record_score: 13.124152090331723, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573339500000, + by_field_name: 'transaction.name', + by_field_value: 'GET /test-data', + function: 'high_mean', + function_description: 'mean', + typical: [802.0600710562369], + actual: [761.1531339031332], + field_name: 'transaction.duration.us', + influencers: [ + { + influencer_field_name: 'transaction.name', + influencer_field_values: ['GET /test-data'], + }, + { + influencer_field_name: 'trace.id', + influencer_field_values: [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + }, + { + influencer_field_name: 'service.name', + influencer_field_values: ['example-service'], + }, + ], + 'trace.id': [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + 'service.name': ['example-service'], + 'transaction.name': ['GET /test-data'], + earliest: '2019-11-09T20:45:00.000Z', + latest: '2019-11-10T01:00:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/traces?rangeFrom=2019-11-09T20:45:00.000Z&rangeTo=2019-11-10T01:00:00.000Z&kuery=(trace.id:"000a09d58a428f38550e7e87637733c1" OR trace.id:"0039c771d8bbadf6137767d3aeb89f96" OR trace.id:"01279ed5bb9f4249e3822d16dec7f2f2") AND transaction.name:"GET%20%2Ftest-data"&_g=()' + ); + }); + + test('removes an empty path component with a trailing slash', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request', + }; + + const testRecords = { + job_id: 'decreased_throughput_jsbase', + result_type: 'record', + probability: 8.91350850732573e-9, + multi_bucket_impact: 5, + record_score: 93.63625728951217, + initial_record_score: 93.63625728951217, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573266600000, + function: 'low_count', + function_description: 'count', + typical: [100615.66506877479], + actual: [25251], + earliest: '2019-11-09T00:30:00.000Z', + latest: '2019-11-09T04:45:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/services/transactions?rangeFrom=2019-11-09T00:30:00.000Z&rangeTo=2019-11-09T04:45:00.000Z&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request' + ); + }); + test('returns expected URL for other type URL', () => { expect(getUrlForRecord(TEST_OTHER_URL, TEST_RECORD)).toBe( 'http://airlinecodes.info/airline-code-AAL' diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts index e2f2dc0ad0fe84..7774f6dec0c953 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts @@ -97,7 +97,11 @@ export function openCustomUrlWindow(fullUrl: string, urlConfig: UrlConfig) { // a Kibana Discover or Dashboard page running on the same server as this ML plugin. function isKibanaUrl(urlConfig: UrlConfig) { const urlValue = urlConfig.url_value; - return urlValue.startsWith('kibana#/discover') || urlValue.startsWith('kibana#/dashboard'); + return ( + urlValue.startsWith('kibana#/discover') || + urlValue.startsWith('kibana#/dashboard') || + urlValue.startsWith('apm#/') + ); } /** @@ -136,13 +140,14 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); - return str.replace(/\$([^?&$\'"]+)\$/g, (match, name: string) => { + // Looking for a $token$ with an optional trailing slash + return str.replace(/\$([^?&$\'"]+)\$(\/)?/g, (match, name: string, slash: string = '') => { // Use lodash get to allow nested JSON fields to be retrieved. let tokenValue: string | string[] | undefined = get(record, name); tokenValue = Array.isArray(tokenValue) ? tokenValue[0] : tokenValue; - // If property not found string is not replaced. - return tokenValue === undefined ? match : getResultTokenValue(tokenValue); + // If property not found token is replaced with an empty string. + return tokenValue === undefined ? '' : getResultTokenValue(tokenValue) + slash; }); }; @@ -155,7 +160,7 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); return str.replace( - /(.+query:')([^']*)('.+)/, + /(.+query:'|.+&kuery=)([^']*)(['&].+)/, (fullMatch, prefix: string, queryString: string, postfix: string) => { const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues); @@ -170,28 +175,39 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) const queryParts: string[] = []; const joinOperator = ' AND '; - for (let i = 0; i < queryFields.length; i++) { + fieldsLoop: for (let i = 0; i < queryFields.length; i++) { const field = queryFields[i]; // Use lodash get to allow nested JSON fields to be retrieved. - const tokenValues: string[] | string | null = get(record, field) || null; + let tokenValues: string[] | string | null = get(record, field) || null; if (tokenValues === null) { continue; } + tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues]; + // Create a pair `influencerField:value`. // In cases where there are multiple influencer field values for an anomaly // combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`. - let result = (Array.isArray(tokenValues) ? tokenValues : [tokenValues]) - .map(value => `${field}:"${getResultTokenValue(value)}"`) - .join(' OR '); - result = tokenValues.length > 1 ? `(${result})` : result; - - // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. - availableCharactersLeft -= result.length - (i === 0 ? 0 : joinOperator.length); - - if (availableCharactersLeft <= 0) { - break; - } else { - queryParts.push(result); + let result = ''; + for (let j = 0; j < tokenValues.length; j++) { + const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue( + tokenValues[j] + )}"`; + + // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. + if (availableCharactersLeft < part.length) { + if (result.length > 0) { + queryParts.push(j > 0 ? `(${result})` : result); + } + break fieldsLoop; + } + + result += part; + + availableCharactersLeft -= result.length; + } + + if (result.length > 0) { + queryParts.push(tokenValues.length > 1 ? `(${result})` : result); } } diff --git a/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts index 2b8838c04cf696..2e176b00443144 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/index_utils.ts @@ -8,7 +8,11 @@ import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; import chrome from 'ui/chrome'; import { Query } from 'src/plugins/data/public'; -import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; +import { + IndexPattern, + IIndexPattern, + IndexPatternsContract, +} from '../../../../../../../src/plugins/data/public'; import { IndexPatternSavedObject, SavedSearchSavedObject } from '../../../common/types/kibana'; let indexPatternCache: IndexPatternSavedObject[] = []; @@ -71,7 +75,7 @@ export function getIndexPatternIdFromName(name: string) { } export async function getIndexPatternAndSavedSearch(savedSearchId: string) { - const resp: { savedSearch: SavedSearchSavedObject | null; indexPattern: IndexPattern | null } = { + const resp: { savedSearch: SavedSearchSavedObject | null; indexPattern: IIndexPattern | null } = { savedSearch: null, indexPattern: null, }; diff --git a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index 2ca21efb0bd6ae..cf13d329182ba6 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -753,4 +753,29 @@ export const elasticsearchJsPlugin = (Client, config, components) => { ], method: 'GET', }); + + ml.categories = ca({ + urls: [ + { + fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/results/categories/<%=categoryId%>', + req: { + jobId: { + type: 'string', + }, + categoryId: { + type: 'string', + }, + }, + }, + { + fmt: '/_xpack/ml/anomaly_detectors/<%=jobId%>/results/categories', + req: { + jobId: { + type: 'string', + }, + }, + }, + ], + method: 'GET', + }); }; diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js index cb268ffede7fae..9c5048daeee3f0 100644 --- a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js @@ -12,6 +12,8 @@ describe('ML - data recognizer', () => { const moduleIds = [ 'apache_ecs', + 'apm_jsbase', + 'apm_nodejs', 'apm_transaction', 'auditbeat_process_docker_ecs', 'auditbeat_process_hosts_ecs', diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json new file mode 100644 index 00000000000000..3905c809fbd7a8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json new file mode 100644 index 00000000000000..e463b34be0fc27 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json @@ -0,0 +1,53 @@ +{ + "id": "apm_jsbase", + "title": "APM: RUM Javascript", + "description": "Detect problematic spans and identify user agents that are potentially causing issues.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { + "filter": [{ "term": { "agent.name": "js-base" } }] + } + }, + "jobs": [ + { + "id": "abnormal_span_durations_jsbase", + "file": "abnormal_span_durations_jsbase.json" + }, + { + "id": "anomalous_error_rate_for_user_agents_jsbase", + "file": "anomalous_error_rate_for_user_agents_jsbase.json" + }, + { + "id": "decreased_throughput_jsbase", + "file": "decreased_throughput_jsbase.json" + }, + { + "id": "high_count_by_user_agent_jsbase", + "file": "high_count_by_user_agent_jsbase.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_jsbase", + "file": "datafeed_abnormal_span_durations_jsbase.json", + "job_id": "abnormal_span_durations_jsbase" + }, + { + "id": "datafeed-anomalous_error_rate_for_user_agents_jsbase", + "file": "datafeed_anomalous_error_rate_for_user_agents_jsbase.json", + "job_id": "anomalous_error_rate_for_user_agents_jsbase" + }, + { + "id": "datafeed-decreased_throughput_jsbase", + "file": "datafeed_decreased_throughput_jsbase.json", + "job_id": "decreased_throughput_jsbase" + }, + { + "id": "datafeed-high_count_by_user_agent_jsbase", + "file": "datafeed_high_count_by_user_agent_jsbase.json", + "job_id": "high_count_by_user_agent_jsbase" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json new file mode 100644 index 00000000000000..e0b51a4dcd05e9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 00000000000000..66fd9858c6885a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are encountering errors at an above normal rate. This can help detect browser compatibility issues.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high error rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "error.exception.message.keyword", + "error.page.url", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/errors?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json new file mode 100644 index 00000000000000..7ecbe2890b826c --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 00000000000000..fbfedcbf47561b --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "exists": { "field": "user_agent.name" } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json new file mode 100644 index 00000000000000..48cba1f1578154 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "js-base" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json new file mode 100644 index 00000000000000..18ca6b13892871 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": [{ "exists": { "field": "user_agent.name" } }] } }, + { "bool": { "filter": { "term": { "processor.event": "transaction" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json new file mode 100644 index 00000000000000..4bc8757f19dc96 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json new file mode 100644 index 00000000000000..7e1316359eabbd --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are making requests at a suspiciously high rate. This is useful in identifying bots.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high request rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json new file mode 100644 index 00000000000000..3905c809fbd7a8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json new file mode 100644 index 00000000000000..1865a33a1d3011 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "apm_nodejs", + "title": "APM: NodeJS", + "description": "Detect abnormal traces, anomalous spans, and identify periods of decreased throughput.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { "filter": [{ "term": { "agent.name": "nodejs" } }] } + }, + "jobs": [ + { + "id": "abnormal_span_durations_nodejs", + "file": "abnormal_span_durations_nodejs.json" + }, + { + "id": "abnormal_trace_durations_nodejs", + "file": "abnormal_trace_durations_nodejs.json" + }, + { + "id": "decreased_throughput_nodejs", + "file": "decreased_throughput_nodejs.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_nodejs", + "file": "datafeed_abnormal_span_durations_nodejs.json", + "job_id": "abnormal_span_durations_nodejs" + }, + { + "id": "datafeed-abnormal_trace_durations_nodejs", + "file": "datafeed_abnormal_trace_durations_nodejs.json", + "job_id": "abnormal_trace_durations_nodejs" + }, + { + "id": "datafeed-decreased_throughput_nodejs", + "file": "datafeed_decreased_throughput_nodejs.json", + "job_id": "decreased_throughput_nodejs" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json new file mode 100644 index 00000000000000..1a8318437790ef --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json new file mode 100644 index 00000000000000..875b49e895a008 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies trace transactions that are processing more slowly than usual.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased trace duration", + "function": "high_mean", + "field_name": "transaction.duration.us", + "by_field_name": "transaction.name" + } + ], + "influencers": [ + "transaction.name", + "trace.id", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\" and transaction.name:\"$transaction.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json new file mode 100644 index 00000000000000..3e4f4877bd0424 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json new file mode 100644 index 00000000000000..d87f809a499406 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json @@ -0,0 +1,13 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must_not": [{ "exists": { "field": "parent.id" } }], + "must": [{ "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json new file mode 100644 index 00000000000000..451957c327dd04 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "nodejs" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json new file mode 100644 index 00000000000000..f63c6289a5cd90 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "transaction.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/index.js b/x-pack/legacy/plugins/ml/server/models/job_service/index.js index 78d099dad6606a..186bcbae84546f 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/index.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/index.js @@ -8,7 +8,7 @@ import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; import { newJobCapsProvider } from './new_job_caps'; -import { newJobChartsProvider } from './new_job'; +import { newJobChartsProvider, categorizationExamplesProvider } from './new_job'; export function jobServiceProvider(callWithRequest, request) { return { @@ -17,5 +17,6 @@ export function jobServiceProvider(callWithRequest, request) { ...groupsProvider(callWithRequest), ...newJobCapsProvider(callWithRequest, request), ...newJobChartsProvider(callWithRequest, request), + ...categorizationExamplesProvider(callWithRequest, request), }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts new file mode 100644 index 00000000000000..34e871a936088c --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/categorization.ts @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; +import { CATEGORY_EXAMPLES_MULTIPLIER } from '../../../../common/constants/new_job'; +import { CategoryId, Category, Token } from '../../../../common/types/categories'; +import { callWithRequestType } from '../../../../common/types/kibana'; + +export function categorizationExamplesProvider(callWithRequest: callWithRequestType) { + async function categorizationExamples( + indexPatternTitle: string, + query: any, + size: number, + categorizationFieldName: string, + timeField: string | undefined, + start: number, + end: number, + analyzer?: any + ) { + if (timeField !== undefined) { + const range = { + range: { + [timeField]: { + gte: start, + format: 'epoch_millis', + }, + }, + }; + + if (query.bool === undefined) { + query.bool = {}; + } + if (query.bool.filter === undefined) { + query.bool.filter = range; + } else { + if (Array.isArray(query.bool.filter)) { + query.bool.filter.push(range); + } else { + query.bool.filter.range = range; + } + } + } + + const results = await callWithRequest('search', { + index: indexPatternTitle, + size, + body: { + _source: categorizationFieldName, + query, + }, + }); + const examples: string[] = results.hits?.hits + ?.map((doc: any) => doc._source[categorizationFieldName]) + .filter((example: string | undefined) => example !== undefined); + + let tokens: Token[] = []; + try { + const { tokens: tempTokens } = await callWithRequest('indices.analyze', { + body: { + ...getAnalyzer(analyzer), + text: examples, + }, + }); + tokens = tempTokens; + } catch (error) { + // fail silently, the tokens could not be loaded + // an empty list of tokens will be returned for each example + } + + const lengths = examples.map(e => e.length); + const sumLengths = lengths.map((s => (a: number) => (s += a))(0)); + + const tokensPerExample: Token[][] = examples.map(e => []); + + tokens.forEach((t, i) => { + for (let g = 0; g < sumLengths.length; g++) { + if (t.start_offset <= sumLengths[g] + g) { + const offset = g > 0 ? sumLengths[g - 1] + g : 0; + tokensPerExample[g].push({ + ...t, + start_offset: t.start_offset - offset, + end_offset: t.end_offset - offset, + }); + break; + } + } + }); + + return examples.map((e, i) => ({ text: e, tokens: tokensPerExample[i] })); + } + + function getAnalyzer(analyzer: any) { + if (typeof analyzer === 'object' && analyzer.tokenizer !== undefined) { + return analyzer; + } else { + return { analyzer: 'standard' }; + } + } + + async function validateCategoryExamples( + indexPatternTitle: string, + query: any, + size: number, + categorizationFieldName: string, + timeField: string | undefined, + start: number, + end: number, + analyzer?: any + ) { + const examples = await categorizationExamples( + indexPatternTitle, + query, + size * CATEGORY_EXAMPLES_MULTIPLIER, + categorizationFieldName, + timeField, + start, + end, + analyzer + ); + + const sortedExamples = examples + .map((e, i) => ({ ...e, origIndex: i })) + .sort((a, b) => b.tokens.length - a.tokens.length); + const validExamples = sortedExamples.filter(e => e.tokens.length > 1); + + return { + valid: sortedExamples.length === 0 ? 0 : validExamples.length / sortedExamples.length, + examples: sortedExamples + .filter( + (e, i) => + i / CATEGORY_EXAMPLES_MULTIPLIER - Math.floor(i / CATEGORY_EXAMPLES_MULTIPLIER) === 0 + ) + .sort((a, b) => a.origIndex - b.origIndex) + .map(e => ({ text: e.text, tokens: e.tokens })), + }; + } + + async function getTotalCategories(jobId: string): Promise<{ total: number }> { + const totalResp = await callWithRequest('search', { + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + term: { + job_id: jobId, + }, + }, + { + exists: { + field: 'category_id', + }, + }, + ], + }, + }, + }, + }); + return totalResp?.hits?.total?.value ?? 0; + } + + async function getTopCategoryCounts(jobId: string, numberOfCategories: number) { + const top = await callWithRequest('search', { + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + term: { + job_id: jobId, + }, + }, + { + term: { + result_type: 'model_plot', + }, + }, + { + term: { + by_field_name: 'mlcategory', + }, + }, + ], + }, + }, + aggs: { + cat_count: { + terms: { + field: 'by_field_value', + size: numberOfCategories, + }, + }, + }, + }, + }); + + const catCounts: Array<{ + id: CategoryId; + count: number; + }> = top.aggregations?.cat_count?.buckets.map((c: any) => ({ + id: c.key, + count: c.doc_count, + })); + return catCounts || []; + } + + async function getCategories( + jobId: string, + catIds: CategoryId[], + size: number + ): Promise { + const categoryFilter = catIds.length + ? { + terms: { + category_id: catIds, + }, + } + : { + exists: { + field: 'category_id', + }, + }; + const result = await callWithRequest('search', { + index: ML_RESULTS_INDEX_PATTERN, + size, + body: { + query: { + bool: { + filter: [ + { + term: { + job_id: jobId, + }, + }, + categoryFilter, + ], + }, + }, + }, + }); + + return result.hits.hits?.map((c: { _source: Category }) => c._source) || []; + } + + async function topCategories(jobId: string, numberOfCategories: number) { + const catCounts = await getTopCategoryCounts(jobId, numberOfCategories); + const categories = await getCategories( + jobId, + catCounts.map(c => c.id), + catCounts.length || numberOfCategories + ); + + const catsById = categories.reduce((p, c) => { + p[c.category_id] = c; + return p; + }, {} as { [id: number]: Category }); + + const total = await getTotalCategories(jobId); + + if (catCounts.length) { + return { + total, + categories: catCounts.map(({ id, count }) => { + return { + count, + category: catsById[id] ?? null, + }; + }), + }; + } else { + return { + total, + categories: categories.map(category => { + return { + category, + }; + }), + }; + } + } + + return { + categorizationExamples, + validateCategoryExamples, + topCategories, + }; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts index a8b6ba494a070a..88ae8caa91e4a1 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts @@ -6,7 +6,7 @@ import { newJobLineChartProvider } from './line_chart'; import { newJobPopulationChartProvider } from './population_chart'; -export type callWithRequestType = (action: string, params: any) => Promise; +import { callWithRequestType } from '../../../../common/types/kibana'; export function newJobChartsProvider(callWithRequest: callWithRequestType) { const { newJobLineChart } = newJobLineChartProvider(callWithRequest); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts index 758b834ed7b3a9..da23efa67d0b5c 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts @@ -5,3 +5,4 @@ */ export { newJobChartsProvider } from './charts'; +export { categorizationExamplesProvider } from './categorization'; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts index 5bb0f399821466..c1a5ad5e38ecc9 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -6,10 +6,9 @@ import { get } from 'lodash'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { callWithRequestType } from '../../../../common/types/kibana'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; -export type callWithRequestType = (action: string, params: any) => Promise; - type DtrIndex = number; type TimeStamp = number; type Value = number | undefined | null; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts index 812a135f6cf08a..ee35f13c44ee60 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -6,10 +6,9 @@ import { get } from 'lodash'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { callWithRequestType } from '../../../../common/types/kibana'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; -export type callWithRequestType = (action: string, params: any) => Promise; - const OVER_FIELD_EXAMPLES_COUNT = 40; type DtrIndex = number; diff --git a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js index 4205027eb5ce26..54d447c2881515 100644 --- a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js +++ b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.js @@ -181,4 +181,22 @@ export function jobRoutes({ commonRouteConfig, elasticsearchPlugin, route }) { ...commonRouteConfig, }, }); + + route({ + method: 'GET', + path: '/api/ml/anomaly_detectors/{jobId}/results/categories/{categoryId}', + handler(request, reply) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const options = { + jobId: request.params.jobId, + categoryId: request.params.categoryId, + }; + return callWithRequest('ml.categories', options) + .then(resp => reply(resp)) + .catch(resp => reply(wrapError(resp))); + }, + config: { + ...commonRouteConfig, + }, + }); } diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.js b/x-pack/legacy/plugins/ml/server/routes/job_service.js index 78c57670b7ab3e..a83b4fa403f65c 100644 --- a/x-pack/legacy/plugins/ml/server/routes/job_service.js +++ b/x-pack/legacy/plugins/ml/server/routes/job_service.js @@ -270,4 +270,50 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route ...commonRouteConfig, }, }); + + route({ + method: 'POST', + path: '/api/ml/jobs/categorization_field_examples', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { validateCategoryExamples } = jobServiceProvider(callWithRequest); + const { + indexPatternTitle, + timeField, + query, + size, + field, + start, + end, + analyzer, + } = request.payload; + return validateCategoryExamples( + indexPatternTitle, + query, + size, + field, + timeField, + start, + end, + analyzer + ).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig, + }, + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/top_categories', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { topCategories } = jobServiceProvider(callWithRequest); + const { jobId, count } = request.payload; + return topCategories(jobId, count).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig, + }, + }); } diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js b/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js index 47e8bc3b46474e..cb7187a8c0753d 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/instance/instance.js @@ -14,8 +14,10 @@ import { EuiPageBody, EuiFlexGroup, EuiPageContent, + EuiScreenReaderOnly, } from '@elastic/eui'; import { Status } from './status'; +import { FormattedMessage } from '@kbn/i18n/react'; export function ApmServerInstance({ summary, metrics, ...props }) { const seriesToShow = [ @@ -45,6 +47,14 @@ export function ApmServerInstance({ summary, metrics, ...props }) { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js index cd9ee6c9433d03..f3a888bf9e905b 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js @@ -8,7 +8,14 @@ import React, { Fragment } from 'react'; import moment from 'moment'; import { uniq, get } from 'lodash'; import { EuiMonitoringTable } from '../../table'; -import { EuiLink, EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { + EuiLink, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { Status } from './status'; import { formatMetric } from '../../../lib/format_number'; import { formatTimestampToDuration } from '../../../../common'; @@ -16,6 +23,7 @@ import { i18n } from '@kbn/i18n'; import { APM_SYSTEM_ID } from '../../../../common/constants'; import { ListingCallOut } from '../../setup_mode/listing_callout'; import { SetupModeBadge } from '../../setup_mode/badge'; +import { FormattedMessage } from '@kbn/i18n/react'; function getColumns(setupMode) { return [ @@ -133,6 +141,14 @@ export function ApmServerInstances({ apms, setupMode }) { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js index 8ef996d4d725d7..c053edc805611b 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/overview/index.js @@ -14,8 +14,10 @@ import { EuiPageBody, EuiPanel, EuiPageContent, + EuiScreenReaderOnly, } from '@elastic/eui'; import { Status } from '../instances/status'; +import { FormattedMessage } from '@kbn/i18n/react'; export function ApmOverview({ stats, metrics, ...props }) { const seriesToShow = [ @@ -45,6 +47,14 @@ export function ApmOverview({ stats, metrics, ...props }) { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js b/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js index f3391965be145b..3fe211c0f2edc4 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js +++ b/x-pack/legacy/plugins/monitoring/public/components/beats/beat/beat.js @@ -15,9 +15,11 @@ import { EuiSpacer, EuiPageContent, EuiPanel, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SummaryStatus } from '../../summary_status'; +import { FormattedMessage } from '@kbn/i18n/react'; export function Beat({ summary, metrics, ...props }) { const metricsToShow = [ @@ -137,6 +139,11 @@ export function Beat({ summary, metrics, ...props }) { + +

+ +

+
{metricsToShow.map((metric, index) => ( diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js index 66af12af3db8fd..dfc9117ef48bcb 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js @@ -6,7 +6,14 @@ import React, { PureComponent } from 'react'; import { uniq, get } from 'lodash'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLink } from '@elastic/eui'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiLink, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { Stats } from 'plugins/monitoring/components/beats'; import { formatMetric } from 'plugins/monitoring/lib/format_number'; import { EuiMonitoringTable } from 'plugins/monitoring/components/table'; @@ -14,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { BEATS_SYSTEM_ID } from '../../../../common/constants'; import { ListingCallOut } from '../../setup_mode/listing_callout'; import { SetupModeBadge } from '../../setup_mode/badge'; +import { FormattedMessage } from '@kbn/i18n/react'; export class Listing extends PureComponent { getColumns() { @@ -139,6 +147,14 @@ export class Listing extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap index 8065cb8a3fa306..22d012a2ccca76 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/beats/overview/__snapshots__/overview.test.js.snap @@ -3,6 +3,15 @@ exports[`Overview that overview page renders normally 1`] = ` + +

+ +

+
-

+

-

+ -

+

-

+ -

+

-

+ + +

+ +

+
-

+

-

+
@@ -45,12 +46,12 @@ function renderLatestActive(latestActive, latestTypes, latestVersions) { -

+

-

+
@@ -59,12 +60,12 @@ function renderLatestActive(latestActive, latestTypes, latestVersions) { -

+

-

+
@@ -109,6 +110,14 @@ export function BeatsOverview({ return ( + +

+ +

+
{renderLatestActive(latestActive, latestTypes, latestVersions)} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js index e65aec8602f400..33b26c7ec56e00 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js @@ -91,12 +91,12 @@ export function AlertsPanel({ alerts, changeUrl }) { -

+

-

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js index 3014a741601075..cad4bbf411c347 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js @@ -11,9 +11,10 @@ import { LogstashPanel } from './logstash_panel'; import { AlertsPanel } from './alerts_panel'; import { BeatsPanel } from './beats_panel'; -import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui'; import { ApmPanel } from './apm_panel'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; export function Overview(props) { const isFromStandaloneCluster = props.cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID; @@ -21,6 +22,14 @@ export function Overview(props) { return ( + +

+ +

+
{!isFromStandaloneCluster ? ( diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap index bb765d0ab7b4ed..37e0039c94ec4c 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr/__snapshots__/ccr.test.js.snap @@ -3,6 +3,15 @@ exports[`Ccr that it renders normally 1`] = ` + +

+ +

+
+ +

+ +

+
{this.renderTable()} diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap index 3d03d13dde1e15..02e42b8647c03a 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap @@ -145,9 +145,9 @@ exports[`CcrShard that it renders normally 1`] = ` -

+

September 27, 2018 9:32:09 AM -

+
-

{formatDateTimeLocal(timestamp)}

+

{formatDateTimeLocal(timestamp)}

{JSON.stringify(stat, null, 2)} diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js index 9e4c70307bec72..a0a339fabef92b 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/index/advanced.js @@ -13,9 +13,11 @@ import { EuiSpacer, EuiFlexGrid, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { IndexDetailStatus } from '../index_detail_status'; import { MonitoringTimeseriesContainer } from '../../chart'; +import { FormattedMessage } from '@kbn/i18n/react'; export const AdvancedIndex = ({ indexSummary, metrics, ...props }) => { const metricsToShow = [ @@ -35,6 +37,14 @@ export const AdvancedIndex = ({ indexSummary, metrics, ...props }) => { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js index 232815e930388e..f8dd7b0af7a17e 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/indices/indices.js @@ -19,6 +19,7 @@ import { EuiPanel, EuiSwitch, EuiSpacer, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -130,6 +131,14 @@ export const ElasticsearchIndices = ({ return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js index cc4d25c2e2edac..6fea34ed9c9016 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/advanced.js @@ -13,9 +13,11 @@ import { EuiSpacer, EuiFlexGrid, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { NodeDetailStatus } from '../node_detail_status'; import { MonitoringTimeseriesContainer } from '../../chart'; +import { FormattedMessage } from '@kbn/i18n/react'; export const AdvancedNode = ({ nodeSummary, metrics, ...props }) => { const metricsToShow = [ @@ -39,6 +41,14 @@ export const AdvancedNode = ({ nodeSummary, metrics, ...props }) => { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js index b585de23c62541..e8b3ef7e680f88 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node/node.js @@ -13,11 +13,13 @@ import { EuiFlexGrid, EuiFlexItem, EuiPanel, + EuiScreenReaderOnly, } from '@elastic/eui'; import { NodeDetailStatus } from '../node_detail_status'; import { Logs } from '../../logs/'; import { MonitoringTimeseriesContainer } from '../../chart'; import { ShardAllocation } from '../shard_allocation/shard_allocation'; +import { FormattedMessage } from '@kbn/i18n/react'; export const Node = ({ nodeSummary, @@ -38,10 +40,17 @@ export const Node = ({ metrics.node_latency, metrics.node_segment_count, ]; - return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js index 428da4ca913aa3..d9cf29f73ce0db 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js @@ -23,10 +23,12 @@ import { EuiCallOut, EuiButton, EuiText, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { FormattedMessage } from '@kbn/i18n/react'; import { ListingCallOut } from '../../setup_mode/listing_callout'; const getNodeTooltip = node => { @@ -399,6 +401,14 @@ export function ElasticsearchNodes({ clusterStatus, showCgroupMetricsElasticsear return ( + +

+ +

+
{renderClusterStatus()} {setupModeCallout} diff --git a/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js index b9cd53ef9fe122..053130076fa77e 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js @@ -13,6 +13,7 @@ import { EuiSpacer, EuiLink, EuiCallOut, + EuiScreenReaderOnly, } from '@elastic/eui'; import { capitalize, get } from 'lodash'; import { ClusterStatus } from '../cluster_status'; @@ -247,6 +248,14 @@ export class KibanaInstances extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/license/index.js b/x-pack/legacy/plugins/monitoring/public/components/license/index.js index 0e92cda66bc625..75534da6fbef34 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/license/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/license/index.js @@ -15,6 +15,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { LicenseStatus, AddLicense } from 'plugins/xpack_main/components'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -61,6 +62,11 @@ export function License(props) { const { status, type, isExpired, expiryDate } = props; return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js index 073fc7fc61bcc9..7efe5863479703 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js @@ -6,7 +6,15 @@ import React, { PureComponent } from 'react'; import { get } from 'lodash'; -import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { + EuiPage, + EuiLink, + EuiPageBody, + EuiPageContent, + EuiPanel, + EuiSpacer, + EuiScreenReaderOnly, +} from '@elastic/eui'; import { formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { ClusterStatus } from '..//cluster_status'; import { EuiMonitoringTable } from '../../table'; @@ -165,6 +173,14 @@ export class Listing extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js index 9ed644aa043d69..ed9769b460d921 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/overview/overview.js @@ -13,9 +13,11 @@ import { EuiSpacer, EuiFlexGrid, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { ClusterStatus } from '../cluster_status'; import { MonitoringTimeseriesContainer } from '../../chart'; +import { FormattedMessage } from '@kbn/i18n/react'; export class Overview extends PureComponent { render() { @@ -29,6 +31,14 @@ export class Overview extends PureComponent { return ( + +

+ +

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js index 32bd1303cdfa0f..72ef7a3b0ec5c3 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_listing/pipeline_listing.js @@ -16,12 +16,14 @@ import { EuiSpacer, EuiFlexGroup, EuiFlexItem, + EuiScreenReaderOnly, } from '@elastic/eui'; import { formatMetric } from '../../../lib/format_number'; import { ClusterStatus } from '../cluster_status'; import { Sparkline } from 'plugins/monitoring/components/sparkline'; import { EuiMonitoringSSPTable } from '../../table'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; export class PipelineListing extends Component { tooltipXValueFormatter(xValue, dateFormat) { @@ -148,6 +150,14 @@ export class PipelineListing extends Component { return ( + +

+ +

+
{this.renderStats()} diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap index b19560df7e951b..6b46371e0ee502 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/__test__/__snapshots__/pipeline_viewer.test.js.snap @@ -3,6 +3,15 @@ exports[`PipelineViewer component passes expected props 1`] = ` + +

+ +

+
+ +

+ +

+
-

+

Filters -

+
diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js index d96a9c535dde70..0a3f2d143d2737 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/pipeline_viewer/views/pipeline_viewer.js @@ -10,7 +10,8 @@ import { DetailDrawer } from './detail_drawer'; import { Queue } from './queue'; import { StatementSection } from './statement_section'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiPage, EuiPageContent, EuiPageBody } from '@elastic/eui'; +import { EuiSpacer, EuiPage, EuiPageContent, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; export class PipelineViewer extends React.Component { constructor() { @@ -42,6 +43,14 @@ export class PipelineViewer extends React.Component { return ( + +

+ +

+
-

{title}

+

{title}

diff --git a/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap index cf096ed1f79142..95dd580775f314 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap @@ -4,6 +4,11 @@ exports[`NoData should show a default message if reason is unknown 1`] = `
+

+ No monitoring data found. +

+

+ No monitoring data found. +

+ +

+ +

+
+ +

+ +

+
{ }, CHECK_DELAY); }); - it('refetches UsageCollectors if uploading to local cluster was not successful', done => { + it('stops refetching UsageCollectors if uploading to local cluster was not successful', async () => { const usageCollectorFetch = sinon .stub() .returns({ type: 'type_usage_collector_test', result: { testData: 12345 } }); @@ -227,12 +227,52 @@ describe('BulkUploader', () => { uploader._onPayload = async () => ({ took: 0, ignored: true, errors: false }); - uploader.start(collectors); - setTimeout(() => { - uploader.stop(); - expect(usageCollectorFetch.callCount).to.be.greaterThan(1); - done(); - }, CHECK_DELAY); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + + expect(uploader._holdSendingUsage).to.eql(true); + expect(usageCollectorFetch.callCount).to.eql(1); + }); + + it('fetches UsageCollectors once uploading to local cluster is successful again', async () => { + const usageCollectorFetch = sinon + .stub() + .returns({ type: 'type_usage_collector_test', result: { usageData: 12345 } }); + + const statsCollectorFetch = sinon + .stub() + .returns({ type: 'type_stats_collector_test', result: { statsData: 12345 } }); + + const collectors = new MockCollectorSet(server, [ + { + fetch: statsCollectorFetch, + isReady: () => true, + formatForBulkUpload: result => result, + isUsageCollector: false, + }, + { + fetch: usageCollectorFetch, + isReady: () => true, + formatForBulkUpload: result => result, + isUsageCollector: true, + }, + ]); + + const uploader = new BulkUploader({ ...server, interval: FETCH_INTERVAL }); + let bulkIgnored = true; + uploader._onPayload = async () => ({ took: 0, ignored: bulkIgnored, errors: false }); + + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + expect(uploader._holdSendingUsage).to.eql(true); + + bulkIgnored = false; + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + await uploader._fetchAndUpload(uploader.filterCollectorSet(collectors)); + + expect(uploader._holdSendingUsage).to.eql(false); + expect(usageCollectorFetch.callCount).to.eql(2); + expect(statsCollectorFetch.callCount).to.eql(3); }); it('calls UsageCollectors if last reported exceeds during a _usageInterval', done => { diff --git a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index 2d81cb23b6b3b5..5e0d8aa4be1fdc 100644 --- a/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/legacy/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -40,8 +40,14 @@ export class BulkUploader { } this._timer = null; + // Hold sending and fetching usage until monitoring.bulk is successful. This means that we + // send usage data on the second tick. But would save a lot of bandwidth fetching usage on + // every tick when ES is failing or monitoring is disabled. + this._holdSendingUsage = false; this._interval = interval; this._lastFetchUsageTime = null; + // Limit sending and fetching usage to once per day once usage is successfully stored + // into the monitoring indices. this._usageInterval = TELEMETRY_COLLECTION_INTERVAL; this._log = { @@ -65,6 +71,29 @@ export class BulkUploader { }); } + filterCollectorSet(usageCollection) { + const successfulUploadInLastDay = + this._lastFetchUsageTime && this._lastFetchUsageTime + this._usageInterval > Date.now(); + + return usageCollection.getFilteredCollectorSet(c => { + // this is internal bulk upload, so filter out API-only collectors + if (c.ignoreForInternalUploader) { + return false; + } + // Only collect usage data at the same interval as telemetry would (default to once a day) + if (usageCollection.isUsageCollector(c)) { + if (this._holdSendingUsage) { + return false; + } + if (successfulUploadInLastDay) { + return false; + } + } + + return true; + }); + } + /* * Start the interval timer * @param {usageCollection} usageCollection object to use for initial the fetch/upload and fetch/uploading on interval @@ -72,31 +101,15 @@ export class BulkUploader { */ start(usageCollection) { this._log.info('Starting monitoring stats collection'); - const filterCollectorSet = _usageCollection => { - const successfulUploadInLastDay = - this._lastFetchUsageTime && this._lastFetchUsageTime + this._usageInterval > Date.now(); - - return _usageCollection.getFilteredCollectorSet(c => { - // this is internal bulk upload, so filter out API-only collectors - if (c.ignoreForInternalUploader) { - return false; - } - // Only collect usage data at the same interval as telemetry would (default to once a day) - if (successfulUploadInLastDay && _usageCollection.isUsageCollector(c)) { - return false; - } - return true; - }); - }; if (this._timer) { clearInterval(this._timer); } else { - this._fetchAndUpload(filterCollectorSet(usageCollection)); // initial fetch + this._fetchAndUpload(this.filterCollectorSet(usageCollection)); // initial fetch } this._timer = setInterval(() => { - this._fetchAndUpload(filterCollectorSet(usageCollection)); + this._fetchAndUpload(this.filterCollectorSet(usageCollection)); }, this._interval); } @@ -146,12 +159,17 @@ export class BulkUploader { const sendSuccessful = !result.ignored && !result.errors; if (!sendSuccessful && hasUsageCollectors) { this._lastFetchUsageTime = null; + this._holdSendingUsage = true; this._log.debug( 'Resetting lastFetchWithUsage because uploading to the cluster was not successful.' ); } - if (sendSuccessful && hasUsageCollectors) { - this._lastFetchUsageTime = Date.now(); + + if (sendSuccessful) { + this._holdSendingUsage = false; + if (hasUsageCollectors) { + this._lastFetchUsageTime = Date.now(); + } } this._log.debug(`Uploaded bulk stats payload to the local cluster`); } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js index 823495298e1e6f..6e8eb3e3c71d3d 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_index_patterns.js @@ -13,9 +13,7 @@ import { INDEX_ALERTS, } from '../../../common/constants'; -export function getIndexPatterns(server, additionalPatterns = {}) { - // wildcard means to search _all_ clusters - const ccs = '*'; +export function getIndexPatterns(server, additionalPatterns = {}, ccs = '*') { const config = server.config(); const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs); const kbnIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_KIBANA, ccs); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js index 57fdbd5cc62383..c08ae91769b9d4 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js @@ -58,7 +58,7 @@ describe('getPaginatedNodes', () => { }, }, }; - const shardStats = { + const nodesShardCount = { nodes: { 1: { shardCount: 10, @@ -78,7 +78,7 @@ describe('getPaginatedNodes', () => { pagination, sort, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [ @@ -98,7 +98,7 @@ describe('getPaginatedNodes', () => { pagination, { ...sort, field: 'foo', direction: 'desc' }, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [ @@ -118,7 +118,7 @@ describe('getPaginatedNodes', () => { pagination, sort, 'tw', - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [{ name: 'two', uuid: 2, isOnline: false, shardCount: 5, foo: 12 }], diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js index 4bfd0090fced06..7581a325909712 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js @@ -29,7 +29,7 @@ import { LISTING_METRICS_NAMES, LISTING_METRICS_PATHS } from './nodes_listing_me * @param {Object} shardStats: per-node information about shards * @return {Array} node info combined with metrics for each node from handle_response */ -export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats) { +export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, nodesShardCount) { checkParam(esIndexPattern, 'esIndexPattern in getNodes'); const start = moment.utc(req.payload.timeRange.min).valueOf(); @@ -104,5 +104,9 @@ export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, s const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); const response = await callWithRequest(req, 'search', params); - return handleResponse(response, clusterStats, shardStats, pageOfNodes, { min, max, bucketSize }); + return handleResponse(response, clusterStats, nodesShardCount, pageOfNodes, { + min, + max, + bucketSize, + }); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js index 15084d952b3438..0023b9515ad1cc 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js @@ -35,7 +35,7 @@ export async function getPaginatedNodes( pagination, sort, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ) { const config = req.server.config(); const size = config.get('xpack.monitoring.max_bucket_size'); @@ -45,7 +45,7 @@ export async function getPaginatedNodes( const clusterState = get(clusterStats, 'cluster_state', { nodes: {} }); for (const node of nodes) { node.isOnline = !isUndefined(get(clusterState, ['nodes', node.uuid])); - node.shardCount = get(shardStats, `nodes[${node.uuid}].shardCount`, 0); + node.shardCount = get(nodesShardCount, `nodes[${node.uuid}].shardCount`, 0); } // `metricSet` defines a list of metrics that are sortable in the UI diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js index 55072a10866417..651fd20d775544 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js @@ -13,17 +13,23 @@ import { uncovertMetricNames } from '../../convert_metric_names'; * Process the response from the get_nodes query * @param {Object} response: response data from get_nodes * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @param {Object} timeOptions: min, max, and bucketSize needed for date histogram creation * @return {Array} node info combined with metrics for each node */ -export function handleResponse(response, clusterStats, shardStats, pageOfNodes, timeOptions = {}) { +export function handleResponse( + response, + clusterStats, + nodesShardCount, + pageOfNodes, + timeOptions = {} +) { if (!get(response, 'hits.hits')) { return []; } const nodeHits = get(response, 'hits.hits', []); - const nodesInfo = mapNodesInfo(nodeHits, clusterStats, shardStats); + const nodesInfo = mapNodesInfo(nodeHits, clusterStats, nodesShardCount); /* * Every node bucket is an object with a field for nodeId and fields for diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js index 23ee614d48ec44..3c719c2ddfbf8c 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js @@ -10,10 +10,10 @@ import { calculateNodeType, getNodeTypeClassLabel } from '../'; /** * @param {Array} nodeHits: info about each node from the hits in the get_nodes query * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @return {Object} summarized info about each node keyed by nodeId */ -export function mapNodesInfo(nodeHits, clusterStats, shardStats) { +export function mapNodesInfo(nodeHits, clusterStats, nodesShardCount) { const clusterState = get(clusterStats, 'cluster_state', { nodes: {} }); return nodeHits.reduce((prev, node) => { @@ -35,7 +35,7 @@ export function mapNodesInfo(nodeHits, clusterStats, shardStats) { isOnline, nodeTypeLabel: nodeTypeLabel, nodeTypeClass: nodeTypeClass, - shardCount: get(shardStats, `nodes[${sourceNode.uuid}].shardCount`, 0), + shardCount: get(nodesShardCount, `nodes[${sourceNode.uuid}].shardCount`, 0), }, }; }, {}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js new file mode 100644 index 00000000000000..e8d484e7021f47 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { checkParam } from '../../error_missing_required'; +import { createQuery } from '../../create_query'; +import { ElasticsearchMetric } from '../../metrics'; +import { calculateIndicesTotals } from './calculate_shard_stat_indices_totals'; + +async function getUnassignedShardData(req, esIndexPattern, cluster) { + const config = req.server.config(); + const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); + const metric = ElasticsearchMetric.getMetricFields(); + + const params = { + index: esIndexPattern, + size: 0, + ignoreUnavailable: true, + body: { + sort: { timestamp: { order: 'desc' } }, + query: createQuery({ + type: 'shards', + clusterUuid: cluster.cluster_uuid, + metric, + filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + }), + aggs: { + indices: { + terms: { + field: 'shard.index', + size: maxBucketSize, + }, + aggs: { + state: { + filter: { + terms: { + 'shard.state': ['UNASSIGNED', 'INITIALIZING'], + }, + }, + aggs: { + primary: { + terms: { + field: 'shard.primary', + size: 2, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + return await callWithRequest(req, 'search', params); +} + +export async function getIndicesUnassignedShardStats(req, esIndexPattern, cluster) { + checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); + + const response = await getUnassignedShardData(req, esIndexPattern, cluster); + const indices = get(response, 'aggregations.indices.buckets', []).reduce((accum, bucket) => { + const index = bucket.key; + const states = get(bucket, 'state.primary.buckets', []); + const unassignedReplica = states + .filter(state => state.key_as_string === 'false') + .reduce((total, state) => total + state.doc_count, 0); + const unassignedPrimary = states + .filter(state => state.key_as_string === 'true') + .reduce((total, state) => total + state.doc_count, 0); + + let status = 'green'; + if (unassignedReplica > 0) { + status = 'yellow'; + } + if (unassignedPrimary > 0) { + status = 'red'; + } + + accum[index] = { + unassigned: { primary: unassignedPrimary, replica: unassignedReplica }, + status, + }; + return accum; + }, {}); + + const indicesTotals = calculateIndicesTotals(indices); + return { indices, indicesTotals }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js new file mode 100644 index 00000000000000..a899b48cdd4342 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getIndicesUnassignedShardStats } from './get_indices_unassigned_shard_stats'; + +describe('getIndicesUnassignedShardStats', () => { + it('should return the unassigned shard stats for indices', async () => { + const indices = { + 12345: { status: 'red', unassigned: { primary: 1, replica: 0 } }, + 6789: { status: 'yellow', unassigned: { primary: 0, replica: 1 } }, + absdf82: { status: 'green', unassigned: { primary: 0, replica: 0 } }, + }; + + const req = { + server: { + config: () => ({ + get: () => {}, + }), + plugins: { + elasticsearch: { + getCluster: () => ({ + callWithRequest: () => ({ + aggregations: { + indices: { + buckets: Object.keys(indices).map(id => ({ + key: id, + state: { + primary: { + buckets: + indices[id].unassigned.primary || indices[id].unassigned.replica + ? [ + { + key_as_string: indices[id].unassigned.primary + ? 'true' + : 'false', + doc_count: 1, + }, + ] + : [], + }, + }, + })), + }, + }, + }), + }), + }, + }, + }, + }; + const esIndexPattern = '*'; + const cluster = {}; + const stats = await getIndicesUnassignedShardStats(req, esIndexPattern, cluster); + expect(stats.indices).toEqual(indices); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js new file mode 100644 index 00000000000000..c11bd4aead693f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { checkParam } from '../../error_missing_required'; +import { createQuery } from '../../create_query'; +import { ElasticsearchMetric } from '../../metrics'; + +async function getShardCountPerNode(req, esIndexPattern, cluster) { + const config = req.server.config(); + const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); + const metric = ElasticsearchMetric.getMetricFields(); + + const params = { + index: esIndexPattern, + size: 0, + ignoreUnavailable: true, + body: { + sort: { timestamp: { order: 'desc' } }, + query: createQuery({ + type: 'shards', + clusterUuid: cluster.cluster_uuid, + metric, + filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + }), + aggs: { + nodes: { + terms: { + field: 'shard.node', + size: maxBucketSize, + }, + }, + }, + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + return await callWithRequest(req, 'search', params); +} + +export async function getNodesShardCount(req, esIndexPattern, cluster) { + checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); + + const response = await getShardCountPerNode(req, esIndexPattern, cluster); + const nodes = get(response, 'aggregations.nodes.buckets', []).reduce((accum, bucket) => { + accum[bucket.key] = { shardCount: bucket.doc_count }; + return accum; + }, {}); + return { nodes }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js new file mode 100644 index 00000000000000..023f12db1bf46f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getNodesShardCount } from './get_nodes_shard_count'; + +describe('getNodeShardCount', () => { + it('should return the shard count per node', async () => { + const nodes = { + 12345: { shardCount: 10 }, + 6789: { shardCount: 1 }, + absdf82: { shardCount: 20 }, + }; + + const req = { + server: { + config: () => ({ + get: () => {}, + }), + plugins: { + elasticsearch: { + getCluster: () => ({ + callWithRequest: () => ({ + aggregations: { + nodes: { + buckets: Object.keys(nodes).map(id => ({ + key: id, + doc_count: nodes[id].shardCount, + })), + }, + }, + }), + }), + }, + }, + }, + }; + const esIndexPattern = '*'; + const cluster = {}; + const counts = await getNodesShardCount(req, esIndexPattern, cluster); + expect(counts.nodes).toEqual(nodes); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js index a718ef8569dbf8..eddd50612cdb13 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js @@ -8,7 +8,7 @@ * @param {Object} config - Kibana config service * @param {Boolean} includeNodes - whether to add the aggs for node shards */ -export function getShardAggs(config, includeNodes) { +export function getShardAggs(config, includeNodes, includeIndices) { const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); const aggSize = 10; const indicesAgg = { @@ -40,7 +40,7 @@ export function getShardAggs(config, includeNodes) { }; return { - ...{ indices: indicesAgg }, + ...{ indices: includeIndices ? indicesAgg : undefined }, ...{ nodes: includeNodes ? nodesAgg : undefined }, }; } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js index c77e03673bb4c4..132e9d6b01dbee 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js @@ -21,11 +21,11 @@ export function handleResponse(resp, includeNodes, includeIndices, cluster) { if (buckets && buckets.length !== 0) { indices = buckets.reduce(normalizeIndexShards, {}); indicesTotals = calculateIndicesTotals(indices); + } - if (includeNodes) { - const masterNode = get(cluster, 'cluster_state.master_node'); - nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); - } + if (includeNodes) { + const masterNode = get(cluster, 'cluster_state.master_node'); + nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); } return { @@ -39,12 +39,19 @@ export function getShardStats( req, esIndexPattern, cluster, - { includeNodes = false, includeIndices = false } = {} + { includeNodes = false, includeIndices = false, indexName = null, nodeUuid = null } = {} ) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); const config = req.server.config(); const metric = ElasticsearchMetric.getMetricFields(); + const filters = [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }]; + if (indexName) { + filters.push({ term: { 'shard.index': indexName } }); + } + if (nodeUuid) { + filters.push({ term: { 'shard.node': nodeUuid } }); + } const params = { index: esIndexPattern, size: 0, @@ -55,10 +62,10 @@ export function getShardStats( type: 'shards', clusterUuid: cluster.cluster_uuid, metric, - filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + filters, }), aggs: { - ...getShardAggs(config, includeNodes), + ...getShardAggs(config, includeNodes, includeIndices), }, }, }; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js index e6380a724590e2..c32e25d9f20d19 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js @@ -60,6 +60,7 @@ export function esIndexRoute(server) { const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeNodes: true, includeIndices: true, + indexName: indexUuid, }); const indexSummary = await getIndexSummary(req, esIndexPattern, shardStats, { clusterUuid, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js index c8cf4bd29e26d1..241b54fbf0c2aa 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js @@ -8,10 +8,10 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getIndices } from '../../../../lib/elasticsearch/indices'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esIndicesRoute(server) { server.route({ @@ -43,13 +43,20 @@ export function esIndicesRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats, { - includeIndices: true, - }); - const indices = await getIndices(req, esIndexPattern, showSystemIndices, shardStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); + const indices = await getIndices( + req, + esIndexPattern, + showSystemIndices, + indicesUnassignedShardStats + ); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), indices, }; } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js index 1876f751dd1667..de3b9863d91414 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js @@ -8,10 +8,10 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getMlJobs } from '../../../../lib/elasticsearch/get_ml_jobs'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function mlJobRoute(server) { server.route({ @@ -39,11 +39,15 @@ export function mlJobRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); const rows = await getMlJobs(req, esIndexPattern); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), rows, }; } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js index 5da2e7128e7e4a..10226d74ed0010 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js @@ -78,6 +78,7 @@ export function esNodeRoute(server) { const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeIndices: true, includeNodes: true, + nodeUuid, }); const nodeSummary = await getNodeSummary(req, esIndexPattern, clusterState, shardStats, { clusterUuid, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js index 88e65332603ad8..fb2d04ecc041d3 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js @@ -8,12 +8,13 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getNodes } from '../../../../lib/elasticsearch/nodes'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; +import { getNodesShardCount } from '../../../../lib/elasticsearch/shards/get_nodes_shard_count'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; import { getPaginatedNodes } from '../../../../lib/elasticsearch/nodes/get_nodes/get_paginated_nodes'; import { LISTING_METRICS_NAMES } from '../../../../lib/elasticsearch/nodes/get_nodes/nodes_listing_metrics'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esNodesRoute(server) { server.route({ @@ -53,10 +54,13 @@ export function esNodesRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats, { - includeNodes: true, - }); - const clusterStatus = getClusterStatus(clusterStats, shardStats); + const nodesShardCount = await getNodesShardCount(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); + const clusterStatus = getClusterStatus(clusterStats, indicesUnassignedShardStats); const metricSet = LISTING_METRICS_NAMES; const { pageOfNodes, totalNodeCount } = await getPaginatedNodes( @@ -69,11 +73,17 @@ export function esNodesRoute(server) { queryText, { clusterStats, - shardStats, + nodesShardCount, } ); - const nodes = await getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats); + const nodes = await getNodes( + req, + esIndexPattern, + pageOfNodes, + clusterStats, + nodesShardCount + ); return { clusterStatus, nodes, totalNodeCount }; } catch (err) { throw handleError(err, req); diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js index 9022471dfb7f81..b0045502fa228c 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js @@ -9,7 +9,6 @@ import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getLastRecovery } from '../../../../lib/elasticsearch/get_last_recovery'; import { getMetrics } from '../../../../lib/details/get_metrics'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { metricSet } from './metric_set_overview'; @@ -18,6 +17,7 @@ import { INDEX_PATTERN_FILEBEAT, } from '../../../../../common/constants'; import { getLogs } from '../../../../lib/logs'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esOverviewRoute(server) { server.route({ @@ -54,10 +54,14 @@ export function esOverviewRoute(server) { getLastRecovery(req, esIndexPattern), getLogs(config, req, filebeatIndexPattern, { clusterUuid, start, end }), ]); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), metrics, logs, shardActivity, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js index 334fcc4ccc26fd..2b6f3b6e71d0fa 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/cluster_setup_status.js @@ -34,6 +34,7 @@ export function clusterSetupStatusRoute(server) { skipLiveData: Joi.boolean().default(false), }), payload: Joi.object({ + ccs: Joi.string().optional(), timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required(), @@ -49,7 +50,7 @@ export function clusterSetupStatusRoute(server) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server); + const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); status = await getCollectionStatus( req, indexPatterns, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js index 01715497cbec4b..2a615b887500d8 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/setup/node_setup_status.js @@ -34,6 +34,7 @@ export function nodeSetupStatusRoute(server) { skipLiveData: Joi.boolean().default(false), }), payload: Joi.object({ + ccs: Joi.string().optional(), timeRange: Joi.object({ min: Joi.date().required(), max: Joi.date().required(), @@ -49,7 +50,7 @@ export function nodeSetupStatusRoute(server) { // the monitoring data. `try/catch` makes it a little more explicit. try { await verifyMonitoringAuth(req); - const indexPatterns = getIndexPatterns(server); + const indexPatterns = getIndexPatterns(server, {}, req.payload.ccs); status = await getCollectionStatus( req, indexPatterns, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json index 1f17a7b78a29ec..584618057256a1 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json @@ -2,6 +2,36 @@ { "hits": { "hits": [ + { + "_source" : { + "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", + "type": "beats_state", + "beats_state" : { + "state" : { + "functionbeat" : { + "functions": { + "count": 1 + } + } + } + } + } + }, + { + "_source" : { + "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", + "type": "beats_state", + "beats_state" : { + "state" : { + "functionbeat" : { + "functions": { + "count": 3 + } + } + } + } + } + }, { "_source" : { "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js index 7734441a302c3a..522be71555fba6 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js @@ -168,6 +168,11 @@ describe('Get Beats Stats', () => { }, monitors: 3, }, + functionbeat: { + functions: { + count: 4, + }, + }, }, FlV4ckTxQ0a78hmBkzzc9A: { count: 405, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js index 94f710d51cc350..5722228b602073 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js @@ -138,6 +138,23 @@ export function processResults( } } + const functionbeatState = get(hit, '_source.beats_state.state.functionbeat'); + if (functionbeatState !== undefined) { + if (!clusters[clusterUuid].hasOwnProperty('functionbeat')) { + clusters[clusterUuid].functionbeat = { + functions: { + count: 0, + }, + }; + } + + clusters[clusterUuid].functionbeat.functions.count += get( + functionbeatState, + 'functions.count', + 0 + ); + } + const stateHost = get(hit, '_source.beats_state.state.host'); if (stateHost !== undefined) { const hostMap = clusterArchitectureMaps[clusterUuid]; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts index 8b1d6f6f198054..34fc1f452fbc06 100644 --- a/x-pack/legacy/plugins/reporting/config.ts +++ b/x-pack/legacy/plugins/reporting/config.ts @@ -14,7 +14,7 @@ export async function config(Joi: any) { enabled: Joi.boolean().default(true), kibanaServer: Joi.object({ protocol: Joi.string().valid(['http', 'https']), - hostname: Joi.string(), + hostname: Joi.string().invalid('0'), port: Joi.number().integer(), }).default(), queue: Joi.object({ diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_config.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js similarity index 79% rename from x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_config.js rename to x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js index 8b5d6f4591ff5f..10980f702d8493 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_config.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js @@ -6,10 +6,9 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; -import { validateConfig } from '../validate_config'; +import { validateEncryptionKey } from '../validate_encryption_key'; -// FAILING: https://github.com/elastic/kibana/issues/51373 -describe.skip('Reporting: Validate config', () => { +describe('Reporting: Validate config', () => { const logger = { warning: sinon.spy(), }; @@ -25,7 +24,7 @@ describe.skip('Reporting: Validate config', () => { set: sinon.stub(), }; - expect(() => validateConfig(config, logger)).not.to.throwError(); + expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts new file mode 100644 index 00000000000000..04f998fd3e5a5d --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import sinon from 'sinon'; +import { ServerFacade } from '../../../../types'; +import { validateServerHost } from '../validate_server_host'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +describe('Reporting: Validate server host setting', () => { + it(`should log a warning and set ${configKey} if server.host is "0"`, () => { + const getStub = sinon.stub(); + getStub.withArgs('server.host').returns('0'); + getStub.withArgs(configKey).returns(undefined); + const config = { + get: getStub, + set: sinon.stub(), + }; + + expect(() => + validateServerHost(({ config: () => config } as unknown) as ServerFacade) + ).to.throwError(); + + sinon.assert.calledWith(config.set, configKey); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 672f90358aba49..79a64bd82d0228 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { ServerFacade, Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; import { validateBrowser } from './validate_browser'; -import { validateConfig } from './validate_config'; +import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; +import { validateServerHost } from './validate_server_host'; export async function runValidations( server: ServerFacade, @@ -18,13 +20,23 @@ export async function runValidations( try { await Promise.all([ validateBrowser(server, browserFactory, logger), - validateConfig(server, logger), + validateEncryptionKey(server, logger), validateMaxContentLength(server, logger), + validateServerHost(server), ]); - logger.debug(`Reporting plugin self-check ok!`); + logger.debug( + i18n.translate('xpack.reporting.selfCheck.ok', { + defaultMessage: `Reporting plugin self-check ok!`, + }) + ); } catch (err) { logger.warning( - `Reporting plugin self-check failed. Please check the Kibana Reporting settings. ${err}` + i18n.translate('xpack.reporting.selfCheck.warning', { + defaultMessage: `Reporting plugin self-check generated a warning: {err}`, + values: { + err, + }, + }) ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_config.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts similarity index 55% rename from x-pack/legacy/plugins/reporting/server/lib/validate/validate_config.ts rename to x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts index a1eb7be6ecae42..e0af94cbdc29cf 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_config.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts @@ -4,17 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import crypto from 'crypto'; import { ServerFacade, Logger } from '../../../types'; -export function validateConfig(serverFacade: ServerFacade, logger: Logger) { +export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { const config = serverFacade.config(); const encryptionKey = config.get('xpack.reporting.encryptionKey'); if (encryptionKey == null) { + // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `Generating a random key for xpack.reporting.encryptionKey. To prevent pending reports from failing on restart, please set ` + - `xpack.reporting.encryptionKey in kibana.yml` + i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { + defaultMessage: + `Generating a random key for {setting}. To prevent pending reports ` + + `from failing on restart, please set {setting} in kibana.yml`, + values: { + setting: 'xpack.reporting.encryptionKey', + }, + }) ); // @ts-ignore: No set() method on KibanaConfig, just get() and has() diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index ca38ce5d635c6a..f91cd40bfd3c73 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -25,6 +25,7 @@ export async function validateMaxContentLength(server: ServerFacade, logger: Log const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { + // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts new file mode 100644 index 00000000000000..f4f4d61246b6ae --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServerFacade } from '../../../types'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +export function validateServerHost(serverFacade: ServerFacade) { + const config = serverFacade.config(); + + const serverHost = config.get('server.host'); + const reportingKibanaHostName = config.get(configKey); + + if (!reportingKibanaHostName && serverHost === '0') { + // @ts-ignore: No set() method on KibanaConfig, just get() and has() + config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work + + throw new Error( + `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + + `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` + ); + } +} diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx index cf8052cbf35806..30a4c1dce1dff7 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.test.tsx @@ -11,16 +11,8 @@ describe('Highlight Details Flyout', () => { it('renders', async () => { const props: Props = { onClose: () => {}, - shard: { - aggregations: [], - id: ['test', 'test', 'test'], - searches: [], - color: '#fff', - time: 123, - relative: 100, - }, + shardName: '[test][test]', operation: { - parent: null, breakdown: [ { color: 'test', @@ -48,8 +40,7 @@ describe('Highlight Details Flyout', () => { query_type: 'test', selfTime: 100, time: 100, - children: [], - timePercentage: 100, + timePercentage: '100', hasChildren: false, visible: true, absoluteColor: '123', diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx index 6ba39d15b83411..40c17df95456a8 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_flyout.tsx @@ -17,11 +17,11 @@ import { import { msToPretty } from '../../utils'; import { HighlightDetailsTable } from './highlight_details_table'; -import { Operation, Shard } from '../../types'; +import { Operation } from '../../types'; export interface Props { - operation: Operation; - shard: Shard; + operation: Omit; + shardName: string; indexName: string; onClose: () => void; } @@ -39,14 +39,12 @@ const FlyoutEntry = ({ ); -export const HighlightDetailsFlyout = ({ indexName, operation, shard, onClose }: Props) => { +export const HighlightDetailsFlyout = ({ indexName, operation, shardName, onClose }: Props) => { return ( onClose()}> {indexName} - - [{/* shard id */ shard.id[0]}][{/* shard number */ shard.id[2]}] - + {shardName} diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx index 4bfa7365de1efa..f41088b7c9b780 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/highlight_details_flyout/highlight_details_table.tsx @@ -36,7 +36,7 @@ export const HighlightDetailsTable = ({ breakdown }: Props) => { { name: 'Percentage', render: (item: BreakdownItem) => ( - + ), }, ]; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx index 4b53b2e3c18c56..e39e37e8656db6 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/percentage_badge.tsx @@ -9,7 +9,7 @@ import { EuiBadge } from '@elastic/eui'; import classNames from 'classnames'; interface Props { - timePercentage: number; + timePercentage: string; label: string; valueType?: 'percent' | 'time'; } diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts index 714b856b54c68c..f26de49138f121 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/normalize_times.ts @@ -1,802 +1,809 @@ export const inputTimes = [ { - type:"BooleanQuery", - description:"hour:1 hour:2 #MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.447365, - breakdown:[ + type: 'BooleanQuery', + description: 'hour:1 hour:2 #MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.447365, + breakdown: [ { - key:"create_weight", - time:401690, - relative:"89.8", - color:"#feb6b6", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 401690, + relative: '89.8', + color: '#feb6b6', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:45672, - relative:"10.2", - color:"#f6eeee", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 45672, + relative: '10.2', + color: '#f6eeee', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - "children":[ + children: [ { - type:"TermQuery", - description:"hour:1", - time:0.192502, - breakdown:[ + type: 'TermQuery', + description: 'hour:1', + time: 0.192502, + breakdown: [ { - key:"create_weight", - time:190989, - relative:"99.2", - color:"#ffb0b0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 190989, + relative: '99.2', + color: '#ffb0b0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:1510, - relative:"0.8", - color:"#f5f4f4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 1510, + relative: '0.8', + color: '#f5f4f4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"3339dca6-c34a-49f3-a534-27e46f238bcd", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.192502 + id: '3339dca6-c34a-49f3-a534-27e46f238bcd', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.192502, }, { - type:"TermQuery", - description:"hour:2", - time:0.162608, - breakdown:[ + type: 'TermQuery', + description: 'hour:2', + time: 0.162608, + breakdown: [ { - key:"create_weight", - time:162016, - relative:"99.6", - color:"#ffafaf", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 162016, + relative: '99.6', + color: '#ffafaf', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:589, - relative:"0.4", - color:"#f5f5f5", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 589, + relative: '0.4', + color: '#f5f5f5', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.162608 + id: '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.162608, }, { - type:"MatchNoDocsQuery", - description:"MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.03517, - breakdown:[ + type: 'MatchNoDocsQuery', + description: 'MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.03517, + breakdown: [ { - key:"build_scorer", - time:32522, - relative:"92.5", - color:"#feb4b4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 32522, + relative: '92.5', + color: '#feb4b4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"create_weight", - time:2645, - relative:"7.5", - color:"#f6f0f0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 2645, + relative: '7.5', + color: '#f6f0f0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.03517 - } + id: 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.03517, + }, ], - id:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - "3339dca6-c34a-49f3-a534-27e46f238bcd", - "9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - "ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb" + id: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [ + '3339dca6-c34a-49f3-a534-27e46f238bcd', + '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', ], - hasChildren:true, - selfTime:0.057085 - } + hasChildren: true, + selfTime: 0.057085, + }, ]; - export const normalizedTimes = [ { - type:"BooleanQuery", - description:"hour:1 hour:2 #MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.447365, - breakdown:[ + type: 'BooleanQuery', + description: 'hour:1 hour:2 #MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.447365, + breakdown: [ { - key:"create_weight", - time:401690, - relative:"89.8", - color:"#feb6b6", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 401690, + relative: '89.8', + color: '#feb6b6', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:45672, - relative:"10.2", - color:"#f6eeee", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 45672, + relative: '10.2', + color: '#f6eeee', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - "children":[ + children: [ { - type:"TermQuery", - description:"hour:1", - time:0.192502, - breakdown:[ + type: 'TermQuery', + description: 'hour:1', + time: 0.192502, + breakdown: [ { - key:"create_weight", - time:190989, - relative:"99.2", - color:"#ffb0b0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 190989, + relative: '99.2', + color: '#ffb0b0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:1510, - relative:"0.8", - color:"#f5f4f4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 1510, + relative: '0.8', + color: '#f5f4f4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"3339dca6-c34a-49f3-a534-27e46f238bcd", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.192502, - timePercentage:"43.03", - absoluteColor:"#f9d7d7", - depth:1 + id: '3339dca6-c34a-49f3-a534-27e46f238bcd', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.192502, + timePercentage: '43.03', + absoluteColor: '#f9d7d7', }, { - type:"TermQuery", - description:"hour:2", - time:0.162608, - breakdown:[ + type: 'TermQuery', + description: 'hour:2', + time: 0.162608, + breakdown: [ { - key:"create_weight", - time:162016, - relative:"99.6", - color:"#ffafaf", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 162016, + relative: '99.6', + color: '#ffafaf', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer", - time:589, - relative:"0.4", - color:"#f5f5f5", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 589, + relative: '0.4', + color: '#f5f5f5', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.162608, - timePercentage:"36.35", - absoluteColor:"#f9dcdc", - depth:1 + id: '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.162608, + timePercentage: '36.35', + absoluteColor: '#f9dcdc', }, { - type:"MatchNoDocsQuery", - description:"MatchNoDocsQuery[\"User requested \"match_none\" query.\"]", - time:0.03517, - breakdown:[ + type: 'MatchNoDocsQuery', + description: 'MatchNoDocsQuery["User requested "match_none" query."]', + time: 0.03517, + breakdown: [ { - key:"build_scorer", - time:32522, - relative:"92.5", - color:"#feb4b4", - tip:"The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc." + key: 'build_scorer', + time: 32522, + relative: '92.5', + color: '#feb4b4', + tip: + 'The time taken to create the Scoring object, which is later used to execute the actual scoring of each doc.', }, { - key:"create_weight", - time:2645, - relative:"7.5", - color:"#f6f0f0", - tip:"The time taken to create the Weight object, which holds temporary information during scoring." + key: 'create_weight', + time: 2645, + relative: '7.5', + color: '#f6f0f0', + tip: + 'The time taken to create the Weight object, which holds temporary information during scoring.', }, { - key:"build_scorer_count", - time:2, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'build_scorer_count', + time: 2, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"create_weight_count", - time:1, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'create_weight_count', + time: 1, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next matching document." + key: 'next_doc', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next matching document.', }, { - key:"match", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to execute a secondary, more precise scoring phase (used by phrase queries)." + key: 'match', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: + 'The time taken to execute a secondary, more precise scoring phase (used by phrase queries).', }, { - key:"match_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'match_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"next_doc_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'next_doc_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" + key: 'score_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', }, { - key:"score", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken in actually scoring the document against the query." + key: 'score', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken in actually scoring the document against the query.', }, { - key:"advance", - time:0, - relative:"0.0", - color:"#f5f5f5", - tip:"The time taken to advance the iterator to the next document." + key: 'advance', + time: 0, + relative: '0.0', + color: '#f5f5f5', + tip: 'The time taken to advance the iterator to the next document.', }, { - key:"advance_count", - time:0, - relative:0, - color:"#f5f5f5", - tip:"" - } - ], - id:"ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb", - parentId:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - + key: 'advance_count', + time: 0, + relative: 0, + color: '#f5f5f5', + tip: '', + }, ], - selfTime:0.03517, - timePercentage:"7.86", - absoluteColor:"#f6efef", - depth:1 - } + id: 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', + parentId: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [], + selfTime: 0.03517, + timePercentage: '7.86', + absoluteColor: '#f6efef', + }, ], - id:"f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803", - childrenIds:[ - "3339dca6-c34a-49f3-a534-27e46f238bcd", - "9b75ecdd-a1da-45eb-8d13-5bc5f472dba3", - "ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb" + id: 'f1e689b1-dafe-4c2b-9a4d-9bd8f1a53803', + childrenIds: [ + '3339dca6-c34a-49f3-a534-27e46f238bcd', + '9b75ecdd-a1da-45eb-8d13-5bc5f472dba3', + 'ddf5aa3e-4b22-4332-9d5e-79a6ae0cc9cb', ], - hasChildren:true, - selfTime:0.057085, - timePercentage:"100.00", - absoluteColor:"#ffafaf", - depth:0 - } + hasChildren: true, + selfTime: 0.057085, + timePercentage: '100.00', + absoluteColor: '#ffafaf', + }, ]; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts index b4483cc0fc58e0..335c6addd40d7e 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/fixtures/search_response.ts @@ -182,6 +182,114 @@ export const searchResponse: any = [ create_weight_count: 1, build_scorer: 17681, }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + children: [ + { + type: 'DocValuesFieldExistsQuery', + description: 'DocValuesFieldExistsQuery [field=_primary_term]', + time_in_nanos: 19795, + breakdown: { + set_min_competitive_score_count: 0, + match_count: 0, + shallow_advance_count: 0, + set_min_competitive_score: 0, + next_doc: 600, + match: 0, + next_doc_count: 5, + score_count: 0, + compute_max_score_count: 0, + compute_max_score: 0, + advance: 378, + advance_count: 3, + score: 0, + build_scorer_count: 6, + create_weight: 1121, + shallow_advance: 0, + create_weight_count: 1, + build_scorer: 17681, + }, + }, + ], + }, + ], + }, + ], + }, + ], }, ], }, diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts index 6cd19947a26bc3..78247f0ccf31bf 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/init_data.test.ts @@ -5,6 +5,9 @@ */ import { ShardSerialized } from '../../../types'; +jest.mock('../constants', () => ({ + MAX_TREE_DEPTH: 3, +})); import { initDataFor } from '../init_data'; import { searchResponse } from './fixtures/search_response'; @@ -15,6 +18,16 @@ describe('ProfileTree init data', () => { const input: ShardSerialized[] = searchResponse as any; const actual = initDataFor('searches')(input); + /* prettier-ignore */ + expect( + actual[1].shards[0].searches![0] + .treeRoot! // level 0 + .children[0] // level 1 + .children[0] // level 2 + .children[0] // level 3 -- Max level! + .children, // level 4 (nothing here!) + ).toEqual([]); + expect(actual[0].name).toEqual(processedResponseWithFirstShard[0].name); const expectedFirstShard = processedResponseWithFirstShard[0].shards[0]; expect(actual[0].shards[0]).toEqual(expectedFirstShard); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts index 17c7051f09769c..ca423310e83754 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/__tests__/unsafe_utils.test.ts @@ -17,13 +17,16 @@ describe('normalizeBreakdown', function() { }); }); -describe('normalizeTimes', function() { +describe('normalizeTime', function() { it('returns correct normalization', function() { const totalTime = 0.447365; // Deep clone the object to preserve the original const input = JSON.parse(JSON.stringify(inputTimes)); - util.normalizeTimes(input, totalTime, 0); + + // Simulate recursive application to the tree. + input.forEach((i: any) => util.normalizeTime(i, totalTime)); + input[0].children.forEach((i: any) => util.normalizeTime(i, totalTime)); // Modifies in place, so inputTimes will change expect(input).to.eql(normalizedTimes); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.ts new file mode 100644 index 00000000000000..b5122f93f1981a --- /dev/null +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/constants.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; + * you may not use this file except in compliance with the Elastic License. + */ + +// Prevent recursive data structures from blowing up the JS call stack. +export const MAX_TREE_DEPTH = 40; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts index 642b2b741abf05..af24a8936c9156 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/init_data.ts @@ -4,18 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; +import cloneDeep from 'lodash.clonedeep'; import { flow } from 'fp-ts/lib/function'; import { Targets, Shard, ShardSerialized } from '../../types'; -import { calcTimes, normalizeTimes, initTree, normalizeIndices, sortIndices } from './unsafe_utils'; +import { calcTimes, initTree, normalizeIndices, sortIndices } from './unsafe_utils'; import { IndexMap } from './types'; /** * Functions prefixed with "mutate" change values by reference. Be careful when using these! - * - * It's recommended to us immer's `produce` functions to ensure immutability. */ - export function mutateAggsTimesTree(shard: Shard) { if (shard.aggregations == null) { shard.time = 0; @@ -26,8 +23,7 @@ export function mutateAggsTimesTree(shard: Shard) { shardTime += totalTime; } for (const agg of shard.aggregations!) { - normalizeTimes([agg], shardTime, 0); - initTree([agg], 0); + initTree([agg], shardTime); } shard.time = shardTime; } @@ -43,66 +39,76 @@ export function mutateSearchTimesTree(shard: Shard) { shard.rewrite_time += search.rewrite_time!; const totalTime = calcTimes(search.query!); shardTime += totalTime; - normalizeTimes(search.query!, totalTime, 0); - initTree(search.query!, 0); + initTree(search.query!, totalTime); search.treeRoot = search.query![0]; + // Remove this object. search.query = null as any; } shard.time = shardTime; } const initShards = (data: ShardSerialized[]) => - produce(data, draft => { - return draft.map(s => { - const idMatch = s.id.match(/\[([^\]\[]*?)\]/g) || []; - const ids = idMatch.map(id => { - return id.replace('[', '').replace(']', ''); - }); - return { - ...s, - id: ids, - time: 0, - color: '', - relative: 0, - }; + data.map(s => { + const idMatch = s.id.match(/\[([^\]\[]*?)\]/g) || []; + const ids = idMatch.map(id => { + return id.replace('[', '').replace(']', ''); }); + return { + ...s, + id: ids, + time: 0, + color: '', + relative: 0, + }; }); -export const calculateShardValues = (target: Targets) => (data: Shard[]) => - produce(data, draft => { - for (const shard of draft) { - if (target === 'searches') { - mutateSearchTimesTree(shard); - } else if (target === 'aggregations') { - mutateAggsTimesTree(shard); - } +export const calculateShardValues = (target: Targets) => (data: Shard[]) => { + const mutateTimesTree = + target === 'searches' + ? mutateSearchTimesTree + : target === 'aggregations' + ? mutateAggsTimesTree + : null; + + if (mutateTimesTree) { + for (const shard of data) { + mutateTimesTree(shard); } - }); + } -export const initIndices = (data: Shard[]) => - produce(data, doNotChange => { - const indices: IndexMap = {}; + return data; +}; - for (const shard of doNotChange) { - if (!indices[shard.id[1]]) { - indices[shard.id[1]] = { - shards: [], - time: 0, - name: shard.id[1], - visible: false, - }; - } - indices[shard.id[1]].shards.push(shard); - indices[shard.id[1]].time += shard.time; +export const initIndices = (data: Shard[]) => { + const indices: IndexMap = {}; + + for (const shard of data) { + if (!indices[shard.id[1]]) { + indices[shard.id[1]] = { + shards: [], + time: 0, + name: shard.id[1], + visible: false, + }; } + indices[shard.id[1]].shards.push(shard); + indices[shard.id[1]].time += shard.time; + } - return indices; - }); + return indices; +}; -export const normalize = (target: Targets) => (data: IndexMap) => - produce(data, draft => { - normalizeIndices(draft, target); - }); +export const normalize = (target: Targets) => (data: IndexMap) => { + normalizeIndices(data, target); + return data; +}; export const initDataFor = (target: Targets) => - flow(initShards, calculateShardValues(target), initIndices, normalize(target), sortIndices); + flow( + cloneDeep, + initShards, + calculateShardValues(target), + initIndices, + normalize(target), + sortIndices + ); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx index eca2d1994f8c56..5ca8ad4ecd9794 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details.tsx @@ -38,7 +38,7 @@ export const ShardDetails = ({ index, shard, operations }: Props) => { diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx index 75da10f8aca2ef..1d8f915d3d47d3 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/shard_details/shard_details_tree_node.tsx @@ -28,8 +28,7 @@ const limitString = (string: string, limit: number) => `${string.slice(0, limit)}${string.length > limit ? '...' : ''}`; /** - * This is a component that recursively iterates over data to render out a tree like - * structure in a flatly. + * This component recursively renders a tree */ export const ShardDetailsTreeNode = ({ operation, index, shard }: Props) => { const [childrenVisible, setChildrenVisible] = useState(hasVisibleChild(operation)); @@ -106,7 +105,7 @@ export const ShardDetailsTreeNode = ({ operation, index, shard }: Props) => {
{childrenVisible && operation.hasChildren && - operation.children.flatMap((childOp, idx) => ( + operation.children.map((childOp, idx) => ( ))} diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts index fc21c5da37764c..2201ad42070b7f 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/components/profile_tree/unsafe_utils.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; import { i18n } from '@kbn/i18n'; import tinycolor from 'tinycolor2'; import _ from 'lodash'; import { BreakdownItem, Index, Operation, Shard, Targets } from '../../types'; import { IndexMap } from './types'; +import { MAX_TREE_DEPTH } from './constants'; export const comparator = (v1: number, v2: number) => { if (v1 < v2) { @@ -53,16 +53,16 @@ function getToolTip(key: string) { } } -export function timeInMilliseconds(data: any) { +export function timeInMilliseconds(data: any): number { if (data.time_in_nanos) { return data.time_in_nanos / 1000000; } if (typeof data.time === 'string') { - return data.time.replace('ms', ''); + return Number(data.time.replace('ms', '')); } - return data.time; + return Number(data.time); } export function calcTimes(data: any[], parentId?: string) { @@ -117,21 +117,6 @@ export function normalizeBreakdown(breakdown: Record) { }); } -export function normalizeTimes(data: any[], totalTime: number, depth: number) { - // Second pass to normalize - for (const child of data) { - child.timePercentage = ((timeInMilliseconds(child) / totalTime) * 100).toFixed(2); - child.absoluteColor = tinycolor.mix('#F5F5F5', '#FFAFAF', child.timePercentage).toHexString(); - child.depth = depth; - - if (child.children != null && child.children.length !== 0) { - normalizeTimes(child.children, totalTime, depth + 1); - } - } - - data.sort((a, b) => comparator(timeInMilliseconds(a), timeInMilliseconds(b))); -} - export function normalizeIndices(indices: IndexMap, target: Targets) { // Sort the shards per-index let sortQueryComponents; @@ -167,7 +152,26 @@ export function normalizeIndices(indices: IndexMap, target: Targets) { } } -export function initTree(data: Operation[], depth = 0, parent: Operation | null = null) { +export function normalizeTime(operation: Operation, totalTime: number) { + operation.timePercentage = ((timeInMilliseconds(operation) / totalTime) * 100).toFixed(2); + operation.absoluteColor = tinycolor + .mix('#F5F5F5', '#FFAFAF', +operation.timePercentage) + .toHexString(); +} + +export function initTree( + data: Operation[], + totalTime: number, + depth = 0, + parent: Operation | null = null +) { + if (MAX_TREE_DEPTH + 1 === depth) { + if (parent) { + parent!.hasChildren = false; + parent!.children = []; + } + return; + } for (const child of data) { // For bwc of older profile responses if (!child.description) { @@ -178,44 +182,28 @@ export function initTree(data: Operation[], depth = 0, parent: Operation | nu child.query_type = null; } - // Use named function for tests. + normalizeTime(child, totalTime); child.parent = parent; child.time = timeInMilliseconds(child); child.lucene = child.description; child.query_type = child.type!.split('.').pop()!; - child.visible = child.timePercentage > 20; + child.visible = +child.timePercentage > 20; child.depth = depth; if (child.children != null && child.children.length !== 0) { - initTree(child.children, depth + 1, child); + initTree(child.children, totalTime, depth + 1, child); } } -} - -export function closeNode(node: Operation) { - const closeDraft = (draft: Operation) => { - draft.visible = false; - if (draft.children == null || draft.children.length === 0) { - return; - } - - for (const child of draft.children) { - closeDraft(child); - } - }; - return produce(node, draft => { - closeDraft(draft); - }); + data.sort((a, b) => comparator(timeInMilliseconds(a), timeInMilliseconds(b))); } -export const sortIndices = (data: IndexMap) => - produce(data, doNotChange => { - const sortedIndices: Index[] = []; - for (const index of Object.values(doNotChange)) { - sortedIndices.push(index); - } - // And now sort the indices themselves - sortedIndices.sort((a, b) => comparator(a.time, b.time)); - return sortedIndices; - }); +export const sortIndices = (data: IndexMap) => { + const sortedIndices: Index[] = []; + for (const index of Object.values(data)) { + sortedIndices.push(index); + } + // And now sort the indices themselves + sortedIndices.sort((a, b) => comparator(a.time, b.time)); + return sortedIndices; +}; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts new file mode 100644 index 00000000000000..14424a1c885298 --- /dev/null +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { reducer } from './reducer'; +import { initialState, State } from './store'; + +describe('Searchprofiler store reducer', () => { + let state: State; + + beforeEach(() => { + state = initialState; + }); + + it('profiles as expected', () => { + const nextState = reducer(state, { type: 'setProfiling', value: true }); + + expect(nextState).toEqual({ + ...state, + pristine: false, + profiling: true, + } as State); + + const finalState = reducer(nextState, { type: 'setProfiling', value: false }); + + expect(finalState).toEqual({ + ...nextState, + pristine: false, + profiling: false, + } as State); + }); + + it('highlights as expected', () => { + const op = { children: null } as any; + const shard = { id: ['a', 'b', 'c'] } as any; + const nextState = reducer(state, { + type: 'setHighlightDetails', + value: { operation: op, indexName: 'test', shard }, + }); + + expect(nextState).toEqual({ + ...state, + highlightDetails: { + operation: { + /* .children no longer defined */ + }, + shardName: '[a][c]', + indexName: 'test', + }, + }); + + const finalState = reducer(state, { + type: 'setHighlightDetails', + value: null, + }); + + expect(finalState).toEqual({ ...state, highlightDetails: null }); + }); +}); diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts index 615511786afd15..394110ac495246 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/reducer.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; import { Reducer } from 'react'; import { State } from './store'; @@ -17,36 +16,49 @@ export type Action = | { type: 'setActiveTab'; value: Targets | null } | { type: 'setCurrentResponse'; value: ShardSerialized[] | null }; -export const reducer: Reducer = (state, action) => - produce(state, draft => { - if (action.type === 'setProfiling') { - draft.pristine = false; - draft.profiling = action.value; - if (draft.profiling) { - draft.currentResponse = null; - draft.highlightDetails = null; - } - return; - } +export const reducer: Reducer = (state, action) => { + const nextState = { ...state }; - if (action.type === 'setHighlightDetails') { - draft.highlightDetails = action.value; - return; + if (action.type === 'setProfiling') { + nextState.pristine = false; + nextState.profiling = action.value; + if (nextState.profiling) { + nextState.currentResponse = null; + nextState.highlightDetails = null; } + return nextState; + } - if (action.type === 'setActiveTab') { - draft.activeTab = action.value; - return; + if (action.type === 'setHighlightDetails') { + if (action.value) { + const value = action.value; + // Exclude children to avoid unnecessary work copying a recursive structure. + const { children, parent, ...restOfOperation } = value.operation; + nextState.highlightDetails = { + indexName: value.indexName, + operation: Object.freeze(restOfOperation), + // prettier-ignore + shardName: `[${/* shard id */value.shard.id[0]}][${/* shard number */value.shard.id[2] }]` + }; + } else { + nextState.highlightDetails = null; } + return nextState; + } + + if (action.type === 'setActiveTab') { + nextState.activeTab = action.value; + return nextState; + } - if (action.type === 'setCurrentResponse') { - draft.currentResponse = action.value; - if (draft.currentResponse) { - // Default to the searches tab - draft.activeTab = 'searches'; - } - return; + if (action.type === 'setCurrentResponse') { + nextState.currentResponse = action.value; + if (nextState.currentResponse) { + // Default to the searches tab + nextState.activeTab = 'searches'; } + return nextState; + } - throw new Error(`Unknown action: ${action}`); - }); + throw new Error(`Unknown action: ${action}`); +}; diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts index 7008854a162858..45194ff917c937 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/store/store.ts @@ -5,13 +5,20 @@ */ import { useReducer } from 'react'; import { reducer } from './reducer'; -import { ShardSerialized, Targets } from '../types'; -import { OnHighlightChangeArgs } from '../components/profile_tree'; +import { Operation, ShardSerialized, Targets } from '../types'; + +export type OperationNoChildParent = Omit; + +interface HighlightDetails { + indexName: string; + operation: OperationNoChildParent; + shardName: string; +} export interface State { profiling: boolean; pristine: boolean; - highlightDetails: OnHighlightChangeArgs | null; + highlightDetails: HighlightDetails | null; activeTab: Targets | null; currentResponse: ShardSerialized[] | null; } diff --git a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts index 2b4dc01c45d6fc..9866f8d5b1ccb4 100644 --- a/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts +++ b/x-pack/legacy/plugins/searchprofiler/public/np_ready/application/types.ts @@ -42,7 +42,7 @@ export interface Operation { hasChildren: boolean; visible: boolean; selfTime: number; - timePercentage: number; + timePercentage: string; absoluteColor: string; time: number; diff --git a/x-pack/legacy/plugins/security/README.md b/x-pack/legacy/plugins/security/README.md index b4786a2df6c521..068f19ba9482b1 100644 --- a/x-pack/legacy/plugins/security/README.md +++ b/x-pack/legacy/plugins/security/README.md @@ -1,7 +1,3 @@ # Kibana Security Plugin -1. Install the Security plugin on Kibana `bin/kibana plugin --install kibana/security/latest` -1. Modify [kibana.yml](https://github.com/elastic/kibana/blob/master/config/kibana.yml) and add `xpack.security.encryptionKey: "something_at_least_32_characters"` -1. Make sure that the following config options are also set: `elasticsearch.username`, `elasticsearch.password`, `server.ssl.certificate`, and `server.ssl.key` (see [Configuring Kibana to Work with Shield](https://www.elastic.co/guide/en/kibana/current/production.html#configuring-kibana-shield)) - -Once done, open up the following url (assuming standard kibana config): [https://localhost:5601](https://localhost:5601). +See [Configuring security in Kibana](https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html). diff --git a/x-pack/legacy/plugins/security/common/model.ts b/x-pack/legacy/plugins/security/common/model.ts index 90e6a5403dfe8e..733e89f774db8c 100644 --- a/x-pack/legacy/plugins/security/common/model.ts +++ b/x-pack/legacy/plugins/security/common/model.ts @@ -11,12 +11,17 @@ export { BuiltinESPrivileges, EditUser, FeaturesPrivileges, + InlineRoleTemplate, + InvalidRoleTemplate, KibanaPrivileges, RawKibanaFeaturePrivileges, RawKibanaPrivileges, Role, RoleIndexPrivilege, RoleKibanaPrivilege, + RoleMapping, + RoleTemplate, + StoredRoleTemplate, User, canUserChangePassword, getUserDisplayName, diff --git a/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts new file mode 100644 index 00000000000000..b8bcba91388b53 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { RoleMapping } from '../../common/model'; + +interface CheckRoleMappingFeaturesResponse { + canManageRoleMappings: boolean; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + hasCompatibleRealms: boolean; +} + +type DeleteRoleMappingsResponse = Array<{ + name: string; + success: boolean; + error?: Error; +}>; + +export class RoleMappingsAPI { + constructor(private readonly http: CoreSetup['http']) {} + + public async checkRoleMappingFeatures(): Promise { + return this.http.get(`/internal/security/_check_role_mapping_features`); + } + + public async getRoleMappings(): Promise { + return this.http.get(`/internal/security/role_mapping`); + } + + public async getRoleMapping(name: string): Promise { + return this.http.get(`/internal/security/role_mapping/${encodeURIComponent(name)}`); + } + + public async saveRoleMapping(roleMapping: RoleMapping) { + const payload = { ...roleMapping }; + delete payload.name; + + return this.http.post( + `/internal/security/role_mapping/${encodeURIComponent(roleMapping.name)}`, + { body: JSON.stringify(payload) } + ); + } + + public async deleteRoleMappings(names: string[]): Promise { + return Promise.all( + names.map(name => + this.http + .delete(`/internal/security/role_mapping/${encodeURIComponent(name)}`) + .then(() => ({ success: true, name })) + .catch(error => ({ success: false, name, error })) + ) + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/_index.scss b/x-pack/legacy/plugins/security/public/views/management/_index.scss index 104fed5980543b..78b53845071e4a 100644 --- a/x-pack/legacy/plugins/security/public/views/management/_index.scss +++ b/x-pack/legacy/plugins/security/public/views/management/_index.scss @@ -1,3 +1,4 @@ @import './change_password_form/index'; @import './edit_role/index'; -@import './edit_user/index'; \ No newline at end of file +@import './edit_user/index'; +@import './role_mappings/edit_role_mapping/index'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts index 7d345ac13dc418..4ab7e45e848498 100644 --- a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts +++ b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts @@ -86,3 +86,30 @@ export function getApiKeysBreadcrumbs() { }, ]; } + +export function getRoleMappingBreadcrumbs() { + return [ + MANAGEMENT_BREADCRUMB, + { + text: i18n.translate('xpack.security.roleMapping.breadcrumb', { + defaultMessage: 'Role Mappings', + }), + href: '#/management/security/role_mappings', + }, + ]; +} + +export function getEditRoleMappingBreadcrumbs($route: Record) { + const { name } = $route.current.params; + return [ + ...getRoleMappingBreadcrumbs(), + { + text: + name || + i18n.translate('xpack.security.roleMappings.createBreadcrumb', { + defaultMessage: 'Create', + }), + href: `#/management/security/role_mappings/edit/${name}`, + }, + ]; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js index 59da63abbb83ff..f0369f232aeba8 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management.js +++ b/x-pack/legacy/plugins/security/public/views/management/management.js @@ -11,9 +11,11 @@ import 'plugins/security/views/management/roles_grid/roles'; import 'plugins/security/views/management/api_keys_grid/api_keys'; import 'plugins/security/views/management/edit_user/edit_user'; import 'plugins/security/views/management/edit_role/index'; +import 'plugins/security/views/management/role_mappings/role_mappings_grid'; +import 'plugins/security/views/management/role_mappings/edit_role_mapping'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls'; +import { ROLES_PATH, USERS_PATH, API_KEYS_PATH, ROLE_MAPPINGS_PATH } from './management_urls'; import { management } from 'ui/management'; import { npSetup } from 'ui/new_platform'; @@ -38,11 +40,23 @@ routes resolve: { securityManagementSection: function() { const showSecurityLinks = xpackInfo.get('features.security.showLinks'); + const showRoleMappingsManagementLink = xpackInfo.get( + 'features.security.showRoleMappingsManagement' + ); function deregisterSecurity() { management.deregister('security'); } + function deregisterRoleMappingsManagement() { + if (management.hasItem('security')) { + const security = management.getSection('security'); + if (security.hasItem('roleMappings')) { + security.deregister('roleMappings'); + } + } + } + function ensureSecurityRegistered() { const registerSecurity = () => management.register('security', { @@ -88,11 +102,26 @@ routes url: `#${API_KEYS_PATH}`, }); } + + if (showRoleMappingsManagementLink && !security.hasItem('roleMappings')) { + security.register('roleMappings', { + name: 'securityRoleMappingLink', + order: 30, + display: i18n.translate('xpack.security.management.roleMappingsTitle', { + defaultMessage: 'Role Mappings', + }), + url: `#${ROLE_MAPPINGS_PATH}`, + }); + } } if (!showSecurityLinks) { deregisterSecurity(); } else { + if (!showRoleMappingsManagementLink) { + deregisterRoleMappingsManagement(); + } + // getCurrentUser will reject if there is no authenticated user, so we prevent them from // seeing the security management screens. return npSetup.plugins.security.authc diff --git a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts index ea0cba9f7f3a7d..881740c0b2895b 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts +++ b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts @@ -12,3 +12,13 @@ export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`; export const USERS_PATH = `${SECURITY_PATH}/users`; export const EDIT_USERS_PATH = `${USERS_PATH}/edit`; export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`; +export const ROLE_MAPPINGS_PATH = `${SECURITY_PATH}/role_mappings`; +export const CREATE_ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/edit`; + +export const getEditRoleHref = (roleName: string) => + `#${EDIT_ROLES_PATH}/${encodeURIComponent(roleName)}`; + +export const getCreateRoleMappingHref = () => `#${CREATE_ROLE_MAPPING_PATH}`; + +export const getEditRoleMappingHref = (roleMappingName: string) => + `#${CREATE_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx new file mode 100644 index 00000000000000..b826d68053e276 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { DeleteProvider } from '.'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { RoleMapping } from '../../../../../../common/model'; +import { EuiConfirmModal } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { act } from '@testing-library/react'; +import { toastNotifications } from 'ui/notify'; + +jest.mock('ui/notify', () => { + return { + toastNotifications: { + addError: jest.fn(), + addSuccess: jest.fn(), + addDanger: jest.fn(), + }, + }; +}); + +describe('DeleteProvider', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('allows a single role mapping to be deleted', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props(); + expect(title).toMatchInlineSnapshot(`"Delete role mapping 'delete-me'?"`); + expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mapping"`); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted role mapping 'delete-me'", + }, + ] + `); + }); + + it('allows multiple role mappings to be deleted', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + { + name: 'delete-me-too', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + { + name: 'delete-me-too', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props(); + expect(title).toMatchInlineSnapshot(`"Delete 2 role mappings?"`); + expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mappings"`); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([ + 'delete-me', + 'delete-me-too', + ]); + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted 2 role mappings", + }, + ] + `); + }); + + it('handles mixed success/failure conditions', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + { + name: 'i-wont-work', + success: false, + error: new Error('something went wrong. sad.'), + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + { + name: 'i-wont-work', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([ + 'delete-me', + 'i-wont-work', + ]); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted role mapping 'delete-me'", + }, + ] + `); + + expect(notifications.addDanger).toHaveBeenCalledTimes(1); + expect(notifications.addDanger.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Error deleting role mapping 'i-wont-work'", + ] + `); + }); + + it('handles errors calling the API', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockImplementation(() => { + throw new Error('AHHHHH'); + }), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(0); + + expect(notifications.addError).toHaveBeenCalledTimes(1); + expect(notifications.addError.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + [Error: AHHHHH], + Object { + "title": "Error deleting role mappings", + }, + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx new file mode 100644 index 00000000000000..2072cedeab4628 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useRef, useState, ReactElement } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; + +interface Props { + roleMappingsAPI: RoleMappingsAPI; + children: (deleteMappings: DeleteRoleMappings) => ReactElement; +} + +export type DeleteRoleMappings = ( + roleMappings: RoleMapping[], + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = (deletedRoleMappings: string[]) => void; + +export const DeleteProvider: React.FunctionComponent = ({ roleMappingsAPI, children }) => { + const [roleMappings, setRoleMappings] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteInProgress, setIsDeleteInProgress] = useState(false); + + const onSuccessCallback = useRef(null); + + const deleteRoleMappingsPrompt: DeleteRoleMappings = ( + roleMappingsToDelete, + onSuccess = () => undefined + ) => { + if (!roleMappingsToDelete || !roleMappingsToDelete.length) { + throw new Error('No Role Mappings specified for delete'); + } + setIsModalOpen(true); + setRoleMappings(roleMappingsToDelete); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + setRoleMappings([]); + }; + + const deleteRoleMappings = async () => { + let result; + + setIsDeleteInProgress(true); + + try { + result = await roleMappingsAPI.deleteRoleMappings(roleMappings.map(rm => rm.name)); + } catch (e) { + toastNotifications.addError(e, { + title: i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.unknownError', + { + defaultMessage: 'Error deleting role mappings', + } + ), + }); + setIsDeleteInProgress(false); + return; + } + + setIsDeleteInProgress(false); + + closeModal(); + + const successfulDeletes = result.filter(res => res.success); + const erroredDeletes = result.filter(res => !res.success); + + // Surface success notifications + if (successfulDeletes.length > 0) { + const hasMultipleSuccesses = successfulDeletes.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.successMultipleNotificationTitle', + { + defaultMessage: 'Deleted {count} role mappings', + values: { count: successfulDeletes.length }, + } + ) + : i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.successSingleNotificationTitle', + { + defaultMessage: "Deleted role mapping '{name}'", + values: { name: successfulDeletes[0].name }, + } + ); + toastNotifications.addSuccess({ + title: successMessage, + 'data-test-subj': 'deletedRoleMappingSuccessToast', + }); + if (onSuccessCallback.current) { + onSuccessCallback.current(successfulDeletes.map(({ name }) => name)); + } + } + + // Surface error notifications + if (erroredDeletes.length > 0) { + const hasMultipleErrors = erroredDeletes.length > 1; + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.errorMultipleNotificationTitle', + { + defaultMessage: 'Error deleting {count} role mappings', + values: { + count: erroredDeletes.length, + }, + } + ) + : i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.errorSingleNotificationTitle', + { + defaultMessage: "Error deleting role mapping '{name}'", + values: { name: erroredDeletes[0].name }, + } + ); + toastNotifications.addDanger(errorMessage); + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + const isSingle = roleMappings.length === 1; + + return ( + + + {!isSingle ? ( + +

+ {i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription', + { defaultMessage: 'You are about to delete these role mappings:' } + )} +

+
    + {roleMappings.map(({ name }) => ( +
  • {name}
  • + ))} +
+
+ ) : null} +
+
+ ); + }; + + return ( + + {children(deleteRoleMappingsPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts new file mode 100644 index 00000000000000..7e8b5a99c3bf53 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DeleteProvider } from './delete_provider'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts new file mode 100644 index 00000000000000..315c1f7ec2baf5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './delete_provider'; +export * from './no_compatible_realms'; +export * from './permission_denied'; +export * from './section_loading'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts new file mode 100644 index 00000000000000..fb2e5b40c1941f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NoCompatibleRealms } from './no_compatible_realms'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx new file mode 100644 index 00000000000000..969832b3ecbae1 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { documentationLinks } from '../../services/documentation_links'; + +export const NoCompatibleRealms: React.FunctionComponent = () => ( + + } + color="warning" + iconType="alert" + > + + + + ), + }} + /> + +); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts new file mode 100644 index 00000000000000..8b0bc67f3f7777 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PermissionDenied } from './permission_denied'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx new file mode 100644 index 00000000000000..1a32645eaedb9d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const PermissionDenied = () => ( + + + + + + } + body={ +

+ +

+ } + /> +
+
+); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts new file mode 100644 index 00000000000000..f59aa7a22d7c2d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SectionLoading } from './section_loading'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx new file mode 100644 index 00000000000000..300f6ca0e1f723 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { SectionLoading } from '.'; + +describe('SectionLoading', () => { + it('renders the default loading message', () => { + const wrapper = shallowWithIntl(); + expect(wrapper.props().body).toMatchInlineSnapshot(` + + + + `); + }); + + it('renders the custom message when provided', () => { + const custom =
hold your horses
; + const wrapper = shallowWithIntl({custom}); + expect(wrapper.props().body).toMatchInlineSnapshot(` + +
+ hold your horses +
+
+ `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx new file mode 100644 index 00000000000000..8ae87127ed3b2d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + children?: React.ReactChild; +} +export const SectionLoading = (props: Props) => { + return ( + } + body={ + + {props.children || ( + + )} + + } + data-test-subj="sectionLoading" + /> + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss new file mode 100644 index 00000000000000..80e08ebcf12267 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss @@ -0,0 +1 @@ +@import './components/rule_editor_panel/index'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx new file mode 100644 index 00000000000000..375a8d9f374a86 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx @@ -0,0 +1,341 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +// brace/ace uses the Worker class, which is not currently provided by JSDOM. +// This is not required for the tests to pass, but it rather suppresses lengthy +// warnings in the console which adds unnecessary noise to the test output. +import 'test_utils/stub_web_worker'; + +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { EditRoleMappingPage } from '.'; +import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../../components'; +import { VisualRuleEditor } from './rule_editor_panel/visual_rule_editor'; +import { JSONRuleEditor } from './rule_editor_panel/json_rule_editor'; +import { EuiComboBox } from '@elastic/eui'; + +jest.mock('../../../../../lib/roles_api', () => { + return { + RolesApi: { + getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]), + }, + }; +}); + +describe('EditRoleMappingPage', () => { + it('allows a role mapping to be created', async () => { + const roleMappingsAPI = ({ + saveRoleMapping: jest.fn().mockResolvedValue(null), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + + await nextTick(); + wrapper.update(); + + findTestSubject(wrapper, 'roleMappingFormNameInput').simulate('change', { + target: { value: 'my-role-mapping' }, + }); + + (wrapper + .find(EuiComboBox) + .filter('[data-test-subj="roleMappingFormRoleComboBox"]') + .props() as any).onChange([{ label: 'foo_role' }]); + + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + + findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click'); + + expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({ + name: 'my-role-mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: { + all: [{ field: { username: '*' } }], + }, + metadata: {}, + }); + }); + + it('allows a role mapping to be updated', async () => { + const roleMappingsAPI = ({ + saveRoleMapping: jest.fn().mockResolvedValue(null), + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + role_templates: [ + { + template: { id: 'foo' }, + }, + ], + enabled: true, + rules: { + any: [{ field: { 'metadata.someCustomOption': [false, true, 'asdf'] } }], + }, + metadata: { + foo: 'bar', + bar: 'baz', + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + await nextTick(); + wrapper.update(); + + findTestSubject(wrapper, 'switchToRolesButton').simulate('click'); + + (wrapper + .find(EuiComboBox) + .filter('[data-test-subj="roleMappingFormRoleComboBox"]') + .props() as any).onChange([{ label: 'foo_role' }]); + + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + wrapper.find('button[id="addRuleOption"]').simulate('click'); + + findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click'); + + expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({ + name: 'foo', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: { + any: [ + { field: { 'metadata.someCustomOption': [false, true, 'asdf'] } }, + { field: { username: '*' } }, + ], + }, + metadata: { + foo: 'bar', + bar: 'baz', + }, + }); + }); + + it('renders a permission denied message when unauthorized to manage role mappings', async () => { + const roleMappingsAPI = ({ + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: false, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(PermissionDenied)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + expect(wrapper.find(PermissionDenied)).toHaveLength(1); + }); + + it('renders a warning when there are no compatible realms enabled', async () => { + const roleMappingsAPI = ({ + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: false, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1); + }); + + it('renders a warning when editing a mapping with a stored role template, when stored scripts are disabled', async () => { + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + role_templates: [ + { + template: { id: 'foo' }, + }, + ], + enabled: true, + rules: { + field: { username: '*' }, + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: false, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1); + }); + + it('renders a warning when editing a mapping with an inline role template, when inline scripts are disabled', async () => { + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + role_templates: [ + { + template: { source: 'foo' }, + }, + ], + enabled: true, + rules: { + field: { username: '*' }, + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: false, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + }); + + it('renders the visual editor by default for simple rule sets', async () => { + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + roles: ['superuser'], + enabled: true, + rules: { + all: [ + { + field: { + username: '*', + }, + }, + { + field: { + dn: null, + }, + }, + { + field: { + realm: ['ldap', 'pki', null, 12], + }, + }, + ], + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + }); + + it('renders the JSON editor by default for complex rule sets', async () => { + const createRule = (depth: number): Record => { + if (depth > 0) { + const rule = { + all: [ + { + field: { + username: '*', + }, + }, + ], + } as Record; + + const subRule = createRule(depth - 1); + if (subRule) { + rule.all.push(subRule); + } + + return rule; + } + return null as any; + }; + + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + roles: ['superuser'], + enabled: true, + rules: createRule(10), + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx new file mode 100644 index 00000000000000..b8a75a4ad9fdf7 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { + EuiForm, + EuiPageContent, + EuiSpacer, + EuiText, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { toastNotifications } from 'ui/notify'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { RuleEditorPanel } from './rule_editor_panel'; +import { + NoCompatibleRealms, + PermissionDenied, + DeleteProvider, + SectionLoading, +} from '../../components'; +import { ROLE_MAPPINGS_PATH } from '../../../management_urls'; +import { validateRoleMappingForSave } from '../services/role_mapping_validation'; +import { MappingInfoPanel } from './mapping_info_panel'; +import { documentationLinks } from '../../services/documentation_links'; + +interface State { + loadState: 'loading' | 'permissionDenied' | 'ready' | 'saveInProgress'; + roleMapping: RoleMapping | null; + hasCompatibleRealms: boolean; + canUseStoredScripts: boolean; + canUseInlineScripts: boolean; + formError: { + isInvalid: boolean; + error?: string; + }; + validateForm: boolean; + rulesValid: boolean; +} + +interface Props { + name?: string; + roleMappingsAPI: RoleMappingsAPI; +} + +export class EditRoleMappingPage extends Component { + constructor(props: any) { + super(props); + this.state = { + loadState: 'loading', + roleMapping: null, + hasCompatibleRealms: true, + canUseStoredScripts: true, + canUseInlineScripts: true, + rulesValid: true, + validateForm: false, + formError: { + isInvalid: false, + }, + }; + } + + public componentDidMount() { + this.loadAppData(); + } + + public render() { + const { loadState } = this.state; + + if (loadState === 'permissionDenied') { + return ; + } + + if (loadState === 'loading') { + return ( + + + + ); + } + + return ( +
+ + {this.getFormTitle()} + + this.setState({ roleMapping })} + mode={this.editingExistingRoleMapping() ? 'edit' : 'create'} + validateForm={this.state.validateForm} + canUseInlineScripts={this.state.canUseInlineScripts} + canUseStoredScripts={this.state.canUseStoredScripts} + /> + + + this.setState({ + roleMapping: { + ...this.state.roleMapping!, + rules, + }, + }) + } + /> + + {this.getFormButtons()} + +
+ ); + } + + private getFormTitle = () => { + return ( + + +

+ {this.editingExistingRoleMapping() ? ( + + ) : ( + + )} +

+
+ +

+ + + + ), + }} + /> +

+
+ {!this.state.hasCompatibleRealms && ( + <> + + + + )} +
+ ); + }; + + private getFormButtons = () => { + return ( + + + + + + + + + + + + + {this.editingExistingRoleMapping() && ( + + + {deleteRoleMappingsPrompt => { + return ( + + deleteRoleMappingsPrompt([this.state.roleMapping!], () => + this.backToRoleMappingsList() + ) + } + color="danger" + > + + + ); + }} + + + )} + + ); + }; + + private onRuleValidityChange = (rulesValid: boolean) => { + this.setState({ + rulesValid, + }); + }; + + private saveRoleMapping = () => { + if (!this.state.roleMapping) { + return; + } + + const { isInvalid } = validateRoleMappingForSave(this.state.roleMapping); + if (isInvalid) { + this.setState({ validateForm: true }); + return; + } + + const roleMappingName = this.state.roleMapping.name; + + this.setState({ + loadState: 'saveInProgress', + }); + + this.props.roleMappingsAPI + .saveRoleMapping(this.state.roleMapping) + .then(() => { + toastNotifications.addSuccess({ + title: i18n.translate('xpack.security.management.editRoleMapping.saveSuccess', { + defaultMessage: `Saved role mapping '{roleMappingName}'`, + values: { + roleMappingName, + }, + }), + 'data-test-subj': 'savedRoleMappingSuccessToast', + }); + this.backToRoleMappingsList(); + }) + .catch(e => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.security.management.editRoleMapping.saveError', { + defaultMessage: `Error saving role mapping`, + }), + toastMessage: e?.body?.message, + }); + + this.setState({ + loadState: 'saveInProgress', + }); + }); + }; + + private editingExistingRoleMapping = () => typeof this.props.name === 'string'; + + private async loadAppData() { + try { + const [features, roleMapping] = await Promise.all([ + this.props.roleMappingsAPI.checkRoleMappingFeatures(), + this.editingExistingRoleMapping() + ? this.props.roleMappingsAPI.getRoleMapping(this.props.name!) + : Promise.resolve({ + name: '', + enabled: true, + metadata: {}, + role_templates: [], + roles: [], + rules: {}, + }), + ]); + + const { + canManageRoleMappings, + canUseStoredScripts, + canUseInlineScripts, + hasCompatibleRealms, + } = features; + + const loadState: State['loadState'] = canManageRoleMappings ? 'ready' : 'permissionDenied'; + + this.setState({ + loadState, + hasCompatibleRealms, + canUseStoredScripts, + canUseInlineScripts, + roleMapping, + }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.security.management.editRoleMapping.table.fetchingRoleMappingsErrorMessage', + { + defaultMessage: 'Error loading role mapping editor: {message}', + values: { message: e?.body?.message ?? '' }, + } + ), + 'data-test-subj': 'errorLoadingRoleMappingEditorToast', + }); + this.backToRoleMappingsList(); + } + } + + private backToRoleMappingsList = () => { + window.location.hash = ROLE_MAPPINGS_PATH; + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts new file mode 100644 index 00000000000000..6758033f92d982 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EditRoleMappingPage } from './edit_role_mapping_page'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts new file mode 100644 index 00000000000000..5042499bf00acb --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MappingInfoPanel } from './mapping_info_panel'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx new file mode 100644 index 00000000000000..d821b33ace6a7b --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { MappingInfoPanel } from '.'; +import { RoleMapping } from '../../../../../../../common/model'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { RoleSelector } from '../role_selector'; +import { RoleTemplateEditor } from '../role_selector/role_template_editor'; + +jest.mock('../../../../../../lib/roles_api', () => { + return { + RolesApi: { + getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]), + }, + }; +}); + +describe('MappingInfoPanel', () => { + it('renders when creating a role mapping, default to the "roles" view', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create', + } as MappingInfoPanel['props']; + + const wrapper = mountWithIntl(); + + // Name input validation + const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject( + wrapper, + 'roleMappingFormNameInput' + ) + .find('input') + .props(); + + expect(nameInputValue).toEqual(props.roleMapping.name); + expect(nameInputReadOnly).toEqual(false); + + // Enabled switch validation + const { checked: enabledInputValue } = wrapper + .find('EuiSwitch[data-test-subj="roleMappingsEnabledSwitch"]') + .props(); + + expect(enabledInputValue).toEqual(props.roleMapping.enabled); + + // Verify "roles" mode + expect(wrapper.find(RoleSelector).props()).toMatchObject({ + mode: 'roles', + }); + }); + + it('renders the role templates view if templates are provided', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [ + { + template: { + source: '', + }, + }, + ], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'edit', + } as MappingInfoPanel['props']; + + const wrapper = mountWithIntl(); + + expect(wrapper.find(RoleSelector).props()).toMatchObject({ + mode: 'templates', + }); + }); + + it('renders a blank inline template by default when switching from roles to role templates', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create' as any, + onChange: jest.fn(), + canUseInlineScripts: true, + canUseStoredScripts: false, + validateForm: false, + }; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [ + { + template: { source: '' }, + }, + ], + rules: {}, + metadata: {}, + }); + + wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] }); + + expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1); + }); + + it('renders a blank stored template by default when switching from roles to role templates and inline scripts are disabled', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create' as any, + onChange: jest.fn(), + canUseInlineScripts: false, + canUseStoredScripts: true, + validateForm: false, + }; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [ + { + template: { id: '' }, + }, + ], + rules: {}, + metadata: {}, + }); + + wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] }); + + expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1); + }); + + it('does not create a blank role template if no script types are enabled', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create' as any, + onChange: jest.fn(), + canUseInlineScripts: false, + canUseStoredScripts: false, + validateForm: false, + }; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click'); + + wrapper.update(); + + expect(props.onChange).not.toHaveBeenCalled(); + expect(wrapper.find(RoleTemplateEditor)).toHaveLength(0); + }); + + it('renders the name input as readonly when editing an existing role mapping', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'edit', + } as MappingInfoPanel['props']; + + const wrapper = mountWithIntl(); + + // Name input validation + const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject( + wrapper, + 'roleMappingFormNameInput' + ) + .find('input') + .props(); + + expect(nameInputValue).toEqual(props.roleMapping.name); + expect(nameInputReadOnly).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx new file mode 100644 index 00000000000000..a02b4fc1709f02 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx @@ -0,0 +1,323 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, ChangeEvent, Fragment } from 'react'; +import { + EuiPanel, + EuiTitle, + EuiText, + EuiSpacer, + EuiDescribedFormGroup, + EuiFormRow, + EuiFieldText, + EuiLink, + EuiIcon, + EuiSwitch, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RoleMapping } from '../../../../../../../common/model'; +import { + validateRoleMappingName, + validateRoleMappingRoles, + validateRoleMappingRoleTemplates, +} from '../../services/role_mapping_validation'; +import { RoleSelector } from '../role_selector'; +import { documentationLinks } from '../../../services/documentation_links'; + +interface Props { + roleMapping: RoleMapping; + onChange: (roleMapping: RoleMapping) => void; + mode: 'create' | 'edit'; + validateForm: boolean; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; +} + +interface State { + rolesMode: 'roles' | 'templates'; +} + +export class MappingInfoPanel extends Component { + constructor(props: Props) { + super(props); + this.state = { + rolesMode: + props.roleMapping.role_templates && props.roleMapping.role_templates.length > 0 + ? 'templates' + : 'roles', + }; + } + public render() { + return ( + + +

+ +

+
+ + {this.getRoleMappingName()} + {this.getEnabledSwitch()} + {this.getRolesOrRoleTemplatesSelector()} +
+ ); + } + + private getRoleMappingName = () => { + return ( + + + + } + description={ + + } + fullWidth + > + + } + fullWidth + {...(this.props.validateForm && validateRoleMappingName(this.props.roleMapping))} + > + + + + ); + }; + + private getRolesOrRoleTemplatesSelector = () => { + if (this.state.rolesMode === 'roles') { + return this.getRolesSelector(); + } + return this.getRoleTemplatesSelector(); + }; + + private getRolesSelector = () => { + const validationFunction = () => { + if (!this.props.validateForm) { + return {}; + } + return validateRoleMappingRoles(this.props.roleMapping); + }; + return ( + + + + } + description={ + + + + + + { + this.onRolesModeChange('templates'); + }} + > + + {' '} + + + + + } + fullWidth + > + + this.props.onChange(roleMapping)} + /> + + + ); + }; + + private getRoleTemplatesSelector = () => { + const validationFunction = () => { + if (!this.props.validateForm) { + return {}; + } + return validateRoleMappingRoleTemplates(this.props.roleMapping); + }; + return ( + + + + } + description={ + + + {' '} + + + + + + { + this.onRolesModeChange('roles'); + }} + data-test-subj="switchToRolesButton" + > + + {' '} + + + + + } + fullWidth + > + + this.props.onChange(roleMapping)} + /> + + + ); + }; + + private getEnabledSwitch = () => { + return ( + + + + } + description={ + + } + fullWidth + > + + } + fullWidth + > + + } + showLabel={false} + data-test-subj="roleMappingsEnabledSwitch" + checked={this.props.roleMapping.enabled} + onChange={e => { + this.props.onChange({ + ...this.props.roleMapping, + enabled: e.target.checked, + }); + }} + /> + + + ); + }; + + private onNameChange = (e: ChangeEvent) => { + const name = e.target.value; + + this.props.onChange({ + ...this.props.roleMapping, + name, + }); + }; + + private onRolesModeChange = (rolesMode: State['rolesMode']) => { + const canUseTemplates = this.props.canUseInlineScripts || this.props.canUseStoredScripts; + if (rolesMode === 'templates' && canUseTemplates) { + // Create blank template as a starting point + const defaultTemplate = this.props.canUseInlineScripts + ? { + template: { source: '' }, + } + : { + template: { id: '' }, + }; + this.props.onChange({ + ...this.props.roleMapping, + roles: [], + role_templates: [defaultTemplate], + }); + } + this.setState({ rolesMode }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx new file mode 100644 index 00000000000000..230664f6fc9976 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { AddRoleTemplateButton } from './add_role_template_button'; + +describe('AddRoleTemplateButton', () => { + it('renders a warning instead of a button if all script types are disabled', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper).toMatchInlineSnapshot(` + + } + > +

+ +

+
+ `); + }); + + it(`asks for an inline template to be created if both script types are enabled`, () => { + const onClickHandler = jest.fn(); + const wrapper = mountWithIntl( + + ); + wrapper.simulate('click'); + expect(onClickHandler).toHaveBeenCalledTimes(1); + expect(onClickHandler).toHaveBeenCalledWith('inline'); + }); + + it(`asks for a stored template to be created if inline scripts are disabled`, () => { + const onClickHandler = jest.fn(); + const wrapper = mountWithIntl( + + ); + wrapper.simulate('click'); + expect(onClickHandler).toHaveBeenCalledTimes(1); + expect(onClickHandler).toHaveBeenCalledWith('stored'); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx new file mode 100644 index 00000000000000..5a78e399bacc7d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + canUseStoredScripts: boolean; + canUseInlineScripts: boolean; + onClick: (templateType: 'inline' | 'stored') => void; +} + +export const AddRoleTemplateButton = (props: Props) => { + if (!props.canUseStoredScripts && !props.canUseInlineScripts) { + return ( + + } + > +

+ +

+
+ ); + } + + const addRoleTemplate = ( + + ); + if (props.canUseInlineScripts) { + return ( + props.onClick('inline')} + data-test-subj="addRoleTemplateButton" + > + {addRoleTemplate} + + ); + } + + return ( + props.onClick('stored')} + data-test-subj="addRoleTemplateButton" + > + {addRoleTemplate} + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx new file mode 100644 index 00000000000000..0011f6ea77bc64 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { RoleSelector } from './role_selector'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx new file mode 100644 index 00000000000000..89815c50e5547f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { EuiComboBox } from '@elastic/eui'; +import { RoleSelector } from './role_selector'; +import { RoleMapping } from '../../../../../../../common/model'; +import { RoleTemplateEditor } from './role_template_editor'; +import { AddRoleTemplateButton } from './add_role_template_button'; + +jest.mock('../../../../../../lib/roles_api', () => { + return { + RolesApi: { + getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]), + }, + }; +}); + +describe('RoleSelector', () => { + it('allows roles to be selected, removing any previously selected role templates', () => { + const props = { + roleMapping: { + roles: [] as string[], + role_templates: [ + { + template: { source: '' }, + }, + ], + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'roles', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + (wrapper.find(EuiComboBox).props() as any).onChange([{ label: 'foo_role' }]); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: ['foo_role'], + role_templates: [], + }); + }); + + it('allows role templates to be created, removing any previously selected roles', () => { + const props = { + roleMapping: { + roles: ['foo_role'], + role_templates: [] as any, + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'templates', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + + wrapper.find(AddRoleTemplateButton).simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: [], + role_templates: [ + { + template: { source: '' }, + }, + ], + }); + }); + + it('allows role templates to be edited', () => { + const props = { + roleMapping: { + roles: [] as string[], + role_templates: [ + { + template: { source: 'foo_role' }, + }, + ], + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'templates', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + + wrapper + .find(RoleTemplateEditor) + .props() + .onChange({ + template: { source: '{{username}}_role' }, + }); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: [], + role_templates: [ + { + template: { source: '{{username}}_role' }, + }, + ], + }); + }); + + it('allows role templates to be deleted', () => { + const props = { + roleMapping: { + roles: [] as string[], + role_templates: [ + { + template: { source: 'foo_role' }, + }, + ], + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'templates', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'deleteRoleTemplateButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: [], + role_templates: [], + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx new file mode 100644 index 00000000000000..6b92d6b4907f16 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; +import { RoleMapping, Role } from '../../../../../../../common/model'; +import { RolesApi } from '../../../../../../lib/roles_api'; +import { AddRoleTemplateButton } from './add_role_template_button'; +import { RoleTemplateEditor } from './role_template_editor'; + +interface Props { + roleMapping: RoleMapping; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + mode: 'roles' | 'templates'; + onChange: (roleMapping: RoleMapping) => void; +} + +interface State { + roles: Role[]; +} + +export class RoleSelector extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { roles: [] }; + } + + public async componentDidMount() { + const roles = await RolesApi.getRoles(); + this.setState({ roles }); + } + + public render() { + const { mode } = this.props; + return ( + + {mode === 'roles' ? this.getRoleComboBox() : this.getRoleTemplates()} + + ); + } + + private getRoleComboBox = () => { + const { roles = [] } = this.props.roleMapping; + return ( + ({ label: r.name }))} + selectedOptions={roles!.map(r => ({ label: r }))} + onChange={selectedOptions => { + this.props.onChange({ + ...this.props.roleMapping, + roles: selectedOptions.map(so => so.label), + role_templates: [], + }); + }} + /> + ); + }; + + private getRoleTemplates = () => { + const { role_templates: roleTemplates = [] } = this.props.roleMapping; + return ( +
+ {roleTemplates.map((rt, index) => ( + + { + const templates = [...(this.props.roleMapping.role_templates || [])]; + templates.splice(index, 1, updatedTemplate); + this.props.onChange({ + ...this.props.roleMapping, + role_templates: templates, + }); + }} + onDelete={() => { + const templates = [...(this.props.roleMapping.role_templates || [])]; + templates.splice(index, 1); + this.props.onChange({ + ...this.props.roleMapping, + role_templates: templates, + }); + }} + /> + + + ))} + { + switch (type) { + case 'inline': { + const templates = this.props.roleMapping.role_templates || []; + this.props.onChange({ + ...this.props.roleMapping, + roles: [], + role_templates: [...templates, { template: { source: '' } }], + }); + break; + } + case 'stored': { + const templates = this.props.roleMapping.role_templates || []; + this.props.onChange({ + ...this.props.roleMapping, + roles: [], + role_templates: [...templates, { template: { id: '' } }], + }); + break; + } + default: + throw new Error(`Unsupported template type: ${type}`); + } + }} + /> +
+ ); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx new file mode 100644 index 00000000000000..6d4af97e12def7 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { RoleTemplateEditor } from './role_template_editor'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +describe('RoleTemplateEditor', () => { + it('allows inline templates to be edited', () => { + const props = { + roleTemplate: { + template: { + source: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + (wrapper + .find('EuiFieldText[data-test-subj="roleTemplateSourceEditor"]') + .props() as any).onChange({ target: { value: 'new_script' } }); + + expect(props.onChange).toHaveBeenCalledWith({ + template: { + source: 'new_script', + }, + }); + }); + + it('warns when editing inline scripts when they are disabled', () => { + const props = { + roleTemplate: { + template: { + source: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: false, + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0); + }); + + it('warns when editing stored scripts when they are disabled', () => { + const props = { + roleTemplate: { + template: { + id: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: false, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0); + }); + + it('allows template types to be changed', () => { + const props = { + roleTemplate: { + template: { + source: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + (wrapper + .find('EuiComboBox[data-test-subj="roleMappingsFormTemplateType"]') + .props() as any).onChange('stored'); + + expect(props.onChange).toHaveBeenCalledWith({ + template: { + id: '', + }, + }); + }); + + it('warns when an invalid role template is specified', () => { + const props = { + roleTemplate: { + template: `This is a string instead of an object if the template was stored in an unparsable format in ES`, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleTemplateSourceEditor')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleTemplateScriptIdEditor')).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx new file mode 100644 index 00000000000000..4b8d34d2719960 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiCallOut, + EuiText, + EuiSwitch, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RoleTemplate } from '../../../../../../../common/model'; +import { + isInlineRoleTemplate, + isStoredRoleTemplate, + isInvalidRoleTemplate, +} from '../../services/role_template_type'; +import { RoleTemplateTypeSelect } from './role_template_type_select'; + +interface Props { + roleTemplate: RoleTemplate; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + onChange: (roleTemplate: RoleTemplate) => void; + onDelete: (roleTemplate: RoleTemplate) => void; +} + +export const RoleTemplateEditor = ({ + roleTemplate, + onChange, + onDelete, + canUseInlineScripts, + canUseStoredScripts, +}: Props) => { + return ( + + {getTemplateConfigurationFields()} + {getEditorForTemplate()} + + + + + onDelete(roleTemplate)} + data-test-subj="deleteRoleTemplateButton" + > + + + + + + + ); + + function getTemplateFormatSwitch() { + const returnsJsonLabel = i18n.translate( + 'xpack.security.management.editRoleMapping.roleTemplateReturnsJson', + { + defaultMessage: 'Returns JSON', + } + ); + + return ( + + { + onChange({ + ...roleTemplate, + format: e.target.checked ? 'json' : 'string', + }); + }} + /> + + ); + } + + function getTemplateConfigurationFields() { + const templateTypeComboBox = ( + + + } + > + + + + ); + + const templateFormatSwitch = {getTemplateFormatSwitch()}; + + return ( + + + {templateTypeComboBox} + {templateFormatSwitch} + + + ); + } + + function getEditorForTemplate() { + if (isInlineRoleTemplate(roleTemplate)) { + const extraProps: Record = {}; + if (!canUseInlineScripts) { + extraProps.isInvalid = true; + extraProps.error = ( + + + + ); + } + const example = '{{username}}_role'; + return ( + + + + } + helpText={ + + } + {...extraProps} + > + { + onChange({ + ...roleTemplate, + template: { + source: e.target.value, + }, + }); + }} + /> + + + + ); + } + + if (isStoredRoleTemplate(roleTemplate)) { + const extraProps: Record = {}; + if (!canUseStoredScripts) { + extraProps.isInvalid = true; + extraProps.error = ( + + + + ); + } + return ( + + + + } + helpText={ + + } + {...extraProps} + > + { + onChange({ + ...roleTemplate, + template: { + id: e.target.value, + }, + }); + }} + /> + + + + ); + } + + if (isInvalidRoleTemplate(roleTemplate)) { + return ( + + + } + > + + + + ); + } + + throw new Error(`Unable to determine role template type`); + } +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx new file mode 100644 index 00000000000000..4a06af0fb436ba --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox } from '@elastic/eui'; +import { RoleTemplate } from '../../../../../../../common/model'; +import { isInlineRoleTemplate, isStoredRoleTemplate } from '../../services/role_template_type'; + +const templateTypeOptions = [ + { + id: 'inline', + label: i18n.translate( + 'xpack.security.management.editRoleMapping.roleTemplate.inlineTypeLabel', + { defaultMessage: 'Role template' } + ), + }, + { + id: 'stored', + label: i18n.translate( + 'xpack.security.management.editRoleMapping.roleTemplate.storedTypeLabel', + { defaultMessage: 'Stored script' } + ), + }, +]; + +interface Props { + roleTemplate: RoleTemplate; + onChange: (roleTempplate: RoleTemplate) => void; + canUseStoredScripts: boolean; + canUseInlineScripts: boolean; +} + +export const RoleTemplateTypeSelect = (props: Props) => { + const availableOptions = templateTypeOptions.filter( + ({ id }) => + (id === 'inline' && props.canUseInlineScripts) || + (id === 'stored' && props.canUseStoredScripts) + ); + + const selectedOptions = templateTypeOptions.filter( + ({ id }) => + (id === 'inline' && isInlineRoleTemplate(props.roleTemplate)) || + (id === 'stored' && isStoredRoleTemplate(props.roleTemplate)) + ); + + return ( + { + const [{ id }] = selected; + if (id === 'inline') { + props.onChange({ + ...props.roleTemplate, + template: { + source: '', + }, + }); + } else { + props.onChange({ + ...props.roleTemplate, + template: { + id: '', + }, + }); + } + }} + isClearable={false} + /> + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss new file mode 100644 index 00000000000000..de64b805997204 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss @@ -0,0 +1,7 @@ +.secRoleMapping__ruleEditorGroup--even { + background-color: $euiColorLightestShade; +} + +.secRoleMapping__ruleEditorGroup--odd { + background-color: $euiColorEmptyShade; +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx new file mode 100644 index 00000000000000..917b822acef3f2 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { AddRuleButton } from './add_rule_button'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { FieldRule, AllRule } from '../../../model'; + +describe('AddRuleButton', () => { + it('allows a field rule to be created', () => { + const props = { + onClick: jest.fn(), + }; + + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1); + + // EUI renders this ID twice, so we need to target the button itself + wrapper.find('button[id="addRuleOption"]').simulate('click'); + + expect(props.onClick).toHaveBeenCalledTimes(1); + + const [newRule] = props.onClick.mock.calls[0]; + expect(newRule).toBeInstanceOf(FieldRule); + expect(newRule.toRaw()).toEqual({ + field: { username: '*' }, + }); + }); + + it('allows a rule group to be created', () => { + const props = { + onClick: jest.fn(), + }; + + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1); + + // EUI renders this ID twice, so we need to target the button itself + wrapper.find('button[id="addRuleGroupOption"]').simulate('click'); + + expect(props.onClick).toHaveBeenCalledTimes(1); + + const [newRule] = props.onClick.mock.calls[0]; + expect(newRule).toBeInstanceOf(AllRule); + expect(newRule.toRaw()).toEqual({ + all: [{ field: { username: '*' } }], + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx new file mode 100644 index 00000000000000..100c0dd3eeaee4 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Rule, FieldRule, AllRule } from '../../../model'; + +interface Props { + onClick: (newRule: Rule) => void; +} + +export const AddRuleButton = (props: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const button = ( + { + setIsMenuOpen(!isMenuOpen); + }} + > + + + ); + + const options = [ + { + setIsMenuOpen(false); + props.onClick(new FieldRule('username', '*')); + }} + > + + , + { + setIsMenuOpen(false); + props.onClick(new AllRule([new FieldRule('username', '*')])); + }} + > + + , + ]; + + return ( + setIsMenuOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="downLeft" + > + + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx new file mode 100644 index 00000000000000..8d5d5c99ee99d7 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FieldRuleEditor } from './field_rule_editor'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FieldRule } from '../../../model'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { ReactWrapper } from 'enzyme'; + +function assertField(wrapper: ReactWrapper, index: number, field: string) { + const isFirst = index === 0; + if (isFirst) { + expect( + wrapper.find(`EuiComboBox[data-test-subj~="fieldRuleEditorField-${index}"]`).props() + ).toMatchObject({ + selectedOptions: [{ label: field }], + }); + + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(1); + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(0); + } else { + expect( + wrapper.find(`EuiExpression[data-test-subj~="fieldRuleEditorField-${index}"]`).props() + ).toMatchObject({ + value: field, + }); + + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(0); + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(1); + } +} + +function assertValueType(wrapper: ReactWrapper, index: number, type: string) { + const valueTypeField = findTestSubject(wrapper, `fieldRuleEditorValueType-${index}`); + expect(valueTypeField.props()).toMatchObject({ value: type }); +} + +function assertValue(wrapper: ReactWrapper, index: number, value: any) { + const valueField = findTestSubject(wrapper, `fieldRuleEditorValue-${index}`); + expect(valueField.props()).toMatchObject({ value }); +} + +describe('FieldRuleEditor', () => { + it('can render a text-based field rule', () => { + const props = { + rule: new FieldRule('username', '*'), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'text'); + assertValue(wrapper, 0, '*'); + }); + + it('can render a number-based field rule', () => { + const props = { + rule: new FieldRule('username', 12), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'number'); + assertValue(wrapper, 0, 12); + }); + + it('can render a null-based field rule', () => { + const props = { + rule: new FieldRule('username', null), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'null'); + assertValue(wrapper, 0, '-- null --'); + }); + + it('can render a boolean-based field rule (true)', () => { + const props = { + rule: new FieldRule('username', true), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'boolean'); + assertValue(wrapper, 0, 'true'); + }); + + it('can render a boolean-based field rule (false)', () => { + const props = { + rule: new FieldRule('username', false), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'boolean'); + assertValue(wrapper, 0, 'false'); + }); + + it('can render with alternate values specified', () => { + const props = { + rule: new FieldRule('username', ['*', 12, null, true, false]), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'addAlternateValueButton')).toHaveLength(1); + + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'text'); + assertValue(wrapper, 0, '*'); + + assertField(wrapper, 1, 'username'); + assertValueType(wrapper, 1, 'number'); + assertValue(wrapper, 1, 12); + + assertField(wrapper, 2, 'username'); + assertValueType(wrapper, 2, 'null'); + assertValue(wrapper, 2, '-- null --'); + + assertField(wrapper, 3, 'username'); + assertValueType(wrapper, 3, 'boolean'); + assertValue(wrapper, 3, 'true'); + + assertField(wrapper, 4, 'username'); + assertValueType(wrapper, 4, 'boolean'); + assertValue(wrapper, 4, 'false'); + }); + + it('allows alternate values to be added when "allowAdd" is set to true', () => { + const props = { + rule: new FieldRule('username', null), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'addAlternateValueButton').simulate('click'); + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule] = props.onChange.mock.calls[0]; + expect(updatedRule.toRaw()).toEqual({ + field: { + username: [null, '*'], + }, + }); + }); + + it('allows values to be deleted; deleting all values invokes "onDelete"', () => { + const props = { + rule: new FieldRule('username', ['*', 12, null]), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + + expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(3); + findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule1] = props.onChange.mock.calls[0]; + expect(updatedRule1.toRaw()).toEqual({ + field: { + username: [12, null], + }, + }); + + props.onChange.mockReset(); + + // simulate updated rule being fed back in + wrapper.setProps({ rule: updatedRule1 }); + + expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(2); + findTestSubject(wrapper, `fieldRuleEditorDeleteValue-1`).simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule2] = props.onChange.mock.calls[0]; + expect(updatedRule2.toRaw()).toEqual({ + field: { + username: [12], + }, + }); + + props.onChange.mockReset(); + + // simulate updated rule being fed back in + wrapper.setProps({ rule: updatedRule2 }); + + expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(1); + findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + expect(props.onDelete).toHaveBeenCalledTimes(1); + }); + + it('allows field data types to be changed', () => { + const props = { + rule: new FieldRule('username', '*'), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + + const { onChange } = findTestSubject(wrapper, `fieldRuleEditorValueType-0`).props(); + onChange!({ target: { value: 'number' } as any } as any); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule] = props.onChange.mock.calls[0]; + expect(updatedRule.toRaw()).toEqual({ + field: { + username: 0, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx new file mode 100644 index 00000000000000..52cf70dbd12bd2 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx @@ -0,0 +1,380 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, ChangeEvent } from 'react'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiComboBox, + EuiSelect, + EuiFieldNumber, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FieldRule, FieldRuleValue } from '../../../model'; + +interface Props { + rule: FieldRule; + onChange: (rule: FieldRule) => void; + onDelete: () => void; +} + +const userFields = [ + { + name: 'username', + }, + { + name: 'dn', + }, + { + name: 'groups', + }, + { + name: 'realm', + }, +]; + +const fieldOptions = userFields.map(f => ({ label: f.name })); + +type ComparisonOption = 'text' | 'number' | 'null' | 'boolean'; +const comparisonOptions: Record< + ComparisonOption, + { id: ComparisonOption; defaultValue: FieldRuleValue } +> = { + text: { + id: 'text', + defaultValue: '*', + }, + number: { + id: 'number', + defaultValue: 0, + }, + null: { + id: 'null', + defaultValue: null, + }, + boolean: { + id: 'boolean', + defaultValue: true, + }, +}; + +export class FieldRuleEditor extends Component { + public render() { + const { field, value } = this.props.rule; + + const content = Array.isArray(value) + ? value.map((v, index) => this.renderFieldRow(field, value, index)) + : [this.renderFieldRow(field, value, 0)]; + + return ( + + {content.map((row, index) => { + return {row}; + })} + + ); + } + + private renderFieldRow = (field: string, ruleValue: FieldRuleValue, valueIndex: number) => { + const isPrimaryRow = valueIndex === 0; + + let renderAddValueButton = true; + let rowRuleValue: FieldRuleValue = ruleValue; + if (Array.isArray(ruleValue)) { + renderAddValueButton = ruleValue.length - 1 === valueIndex; + rowRuleValue = ruleValue[valueIndex]; + } + + const comparisonType = this.getComparisonType(rowRuleValue); + + return ( + + + {isPrimaryRow ? ( + + + + ) : ( + + + + )} + + + {this.renderFieldTypeInput(comparisonType.id, valueIndex)} + + + {this.renderFieldValueInput(comparisonType.id, rowRuleValue, valueIndex)} + + + + {renderAddValueButton ? ( + + ) : ( + + )} + + + + + this.onRemoveAlternateValue(valueIndex)} + /> + + + + ); + }; + + private renderFieldTypeInput = (inputType: ComparisonOption, valueIndex: number) => { + return ( + + + this.onComparisonTypeChange(valueIndex, e.target.value as ComparisonOption) + } + /> + + ); + }; + + private renderFieldValueInput = ( + fieldType: ComparisonOption, + rowRuleValue: FieldRuleValue, + valueIndex: number + ) => { + const inputField = this.getInputFieldForType(fieldType, rowRuleValue, valueIndex); + + return ( + + {inputField} + + ); + }; + + private getInputFieldForType = ( + fieldType: ComparisonOption, + rowRuleValue: FieldRuleValue, + valueIndex: number + ) => { + const isNullValue = rowRuleValue === null; + + const commonProps = { + 'data-test-subj': `fieldRuleEditorValue-${valueIndex}`, + }; + + switch (fieldType) { + case 'boolean': + return ( + + ); + case 'text': + case 'null': + return ( + + ); + case 'number': + return ( + + ); + default: + throw new Error(`Unsupported input field type: ${fieldType}`); + } + }; + + private onAddAlternateValue = () => { + const { field, value } = this.props.rule; + const nextValue = Array.isArray(value) ? [...value] : [value]; + nextValue.push('*'); + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onRemoveAlternateValue = (index: number) => { + const { field, value } = this.props.rule; + + if (!Array.isArray(value) || value.length === 1) { + // Only one value left. Delete entire rule instead. + this.props.onDelete(); + return; + } + const nextValue = [...value]; + nextValue.splice(index, 1); + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onFieldChange = ([newField]: Array<{ label: string }>) => { + if (!newField) { + return; + } + + const { value } = this.props.rule; + this.props.onChange(new FieldRule(newField.label, value)); + }; + + private onAddField = (newField: string) => { + const { value } = this.props.rule; + this.props.onChange(new FieldRule(newField, value)); + }; + + private onValueChange = (index: number) => (e: ChangeEvent) => { + const { field, value } = this.props.rule; + let nextValue; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, e.target.value); + } else { + nextValue = e.target.value; + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onNumericValueChange = (index: number) => (e: ChangeEvent) => { + const { field, value } = this.props.rule; + let nextValue; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, parseFloat(e.target.value)); + } else { + nextValue = parseFloat(e.target.value); + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onBooleanValueChange = (index: number) => (e: ChangeEvent) => { + const boolValue = e.target.value === 'true'; + + const { field, value } = this.props.rule; + let nextValue; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, boolValue); + } else { + nextValue = boolValue; + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onComparisonTypeChange = (index: number, newType: ComparisonOption) => { + const comparison = comparisonOptions[newType]; + if (!comparison) { + throw new Error(`Unexpected comparison type: ${newType}`); + } + const { field, value } = this.props.rule; + let nextValue = value; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, comparison.defaultValue as any); + } else { + nextValue = comparison.defaultValue; + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private getComparisonType(ruleValue: FieldRuleValue) { + const valueType = typeof ruleValue; + if (valueType === 'string' || valueType === 'undefined') { + return comparisonOptions.text; + } + if (valueType === 'number') { + return comparisonOptions.number; + } + if (valueType === 'boolean') { + return comparisonOptions.boolean; + } + if (ruleValue === null) { + return comparisonOptions.null; + } + throw new Error(`Unable to detect comparison type for rule value [${ruleValue}]`); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx new file mode 100644 index 00000000000000..dc09cb1e591fa8 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { RuleEditorPanel } from './rule_editor_panel'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx new file mode 100644 index 00000000000000..8a9b37ab0f4065 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 'brace'; +import 'brace/mode/json'; + +// brace/ace uses the Worker class, which is not currently provided by JSDOM. +// This is not required for the tests to pass, but it rather suppresses lengthy +// warnings in the console which adds unnecessary noise to the test output. +import 'test_utils/stub_web_worker'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { JSONRuleEditor } from './json_rule_editor'; +import { EuiCodeEditor } from '@elastic/eui'; +import { AllRule, AnyRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model'; + +describe('JSONRuleEditor', () => { + it('renders an empty rule set', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + expect(props.onChange).not.toHaveBeenCalled(); + expect(props.onValidityChange).not.toHaveBeenCalled(); + + expect(wrapper.find(EuiCodeEditor).props().value).toMatchInlineSnapshot(`"{}"`); + }); + + it('renders a rule set', () => { + const props = { + rules: new AllRule([ + new AnyRule([new FieldRule('username', '*')]), + new ExceptAnyRule([ + new FieldRule('metadata.foo.bar', '*'), + new AllRule([new FieldRule('realm', 'special-one')]), + ]), + new ExceptAllRule([new FieldRule('realm', '*')]), + ]), + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const { value } = wrapper.find(EuiCodeEditor).props(); + expect(JSON.parse(value)).toEqual({ + all: [ + { + any: [{ field: { username: '*' } }], + }, + { + except: { + any: [ + { field: { 'metadata.foo.bar': '*' } }, + { + all: [{ field: { realm: 'special-one' } }], + }, + ], + }, + }, + { + except: { + all: [{ field: { realm: '*' } }], + }, + }, + ], + }); + }); + + it('notifies when input contains invalid JSON', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const allRule = JSON.stringify(new AllRule().toRaw()); + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(allRule + ', this makes invalid JSON'); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(false); + expect(props.onChange).not.toHaveBeenCalled(); + }); + + it('notifies when input contains an invalid rule set, even if it is valid JSON', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const invalidRule = JSON.stringify({ + all: [ + { + field: { + foo: {}, + }, + }, + ], + }); + + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(invalidRule); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(false); + expect(props.onChange).not.toHaveBeenCalled(); + }); + + it('fires onChange when a valid rule set is provided after being previously invalidated', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const allRule = JSON.stringify(new AllRule().toRaw()); + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(allRule + ', this makes invalid JSON'); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(false); + expect(props.onChange).not.toHaveBeenCalled(); + + props.onValidityChange.mockReset(); + + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(allRule); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(true); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule] = props.onChange.mock.calls[0]; + expect(JSON.stringify(updatedRule.toRaw())).toEqual(allRule); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx new file mode 100644 index 00000000000000..371fb59f7a5d13 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment } from 'react'; + +import 'brace/mode/json'; +import 'brace/theme/github'; +import { EuiCodeEditor, EuiFormRow, EuiButton, EuiSpacer, EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { Rule, RuleBuilderError, generateRulesFromRaw } from '../../../model'; +import { documentationLinks } from '../../../services/documentation_links'; + +interface Props { + rules: Rule | null; + onChange: (updatedRules: Rule | null) => void; + onValidityChange: (isValid: boolean) => void; +} + +export const JSONRuleEditor = (props: Props) => { + const [rawRules, setRawRules] = useState( + JSON.stringify(props.rules ? props.rules.toRaw() : {}, null, 2) + ); + + const [ruleBuilderError, setRuleBuilderError] = useState(null); + + function onRulesChange(updatedRules: string) { + setRawRules(updatedRules); + // Fire onChange only if rules are valid + try { + const ruleJSON = JSON.parse(updatedRules); + props.onChange(generateRulesFromRaw(ruleJSON).rules); + props.onValidityChange(true); + setRuleBuilderError(null); + } catch (e) { + if (e instanceof RuleBuilderError) { + setRuleBuilderError(e); + } else { + setRuleBuilderError(null); + } + props.onValidityChange(false); + } + } + + function reformatRules() { + try { + const ruleJSON = JSON.parse(rawRules); + setRawRules(JSON.stringify(ruleJSON, null, 2)); + } catch (ignore) { + // ignore + } + } + + return ( + + + + + + + + + +

+ + + + ), + }} + /> +

+
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx new file mode 100644 index 00000000000000..809264183d30ca --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { RuleEditorPanel } from '.'; +import { VisualRuleEditor } from './visual_rule_editor'; +import { JSONRuleEditor } from './json_rule_editor'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +// brace/ace uses the Worker class, which is not currently provided by JSDOM. +// This is not required for the tests to pass, but it rather suppresses lengthy +// warnings in the console which adds unnecessary noise to the test output. +import 'test_utils/stub_web_worker'; +import { AllRule, FieldRule } from '../../../model'; +import { EuiErrorBoundary } from '@elastic/eui'; + +describe('RuleEditorPanel', () => { + it('renders the visual editor when no rules are defined', () => { + const props = { + rawRules: {}, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + }); + + it('allows switching to the JSON editor, carrying over rules', () => { + const props = { + rawRules: { + all: [ + { + field: { + username: ['*'], + }, + }, + ], + }, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + + findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click'); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + + const jsonEditor = wrapper.find(JSONRuleEditor); + expect(jsonEditor).toHaveLength(1); + const { rules } = jsonEditor.props(); + expect(rules!.toRaw()).toEqual(props.rawRules); + }); + + it('allows switching to the visual editor, carrying over rules', () => { + const props = { + rawRules: { + field: { username: '*' }, + }, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click'); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(1); + + const jsonEditor = wrapper.find(JSONRuleEditor); + expect(jsonEditor).toHaveLength(1); + const { rules: initialRules, onChange } = jsonEditor.props(); + expect(initialRules?.toRaw()).toEqual({ + field: { username: '*' }, + }); + + onChange(new AllRule([new FieldRule('otherRule', 12)])); + + findTestSubject(wrapper, 'roleMappingsVisualRuleEditorButton').simulate('click'); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [rules] = props.onChange.mock.calls[0]; + expect(rules).toEqual({ + all: [{ field: { otherRule: 12 } }], + }); + }); + + it('catches errors thrown by child components', () => { + const props = { + rawRules: {}, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + + wrapper.find(VisualRuleEditor).simulateError(new Error('Something awful happened here.')); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + expect(wrapper.find(EuiErrorBoundary)).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx new file mode 100644 index 00000000000000..4aab49b2b2efcf --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx @@ -0,0 +1,298 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { + EuiSpacer, + EuiConfirmModal, + EuiOverlayMask, + EuiCallOut, + EuiErrorBoundary, + EuiIcon, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiFormRow, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../../common/model'; +import { VisualRuleEditor } from './visual_rule_editor'; +import { JSONRuleEditor } from './json_rule_editor'; +import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants'; +import { Rule, generateRulesFromRaw } from '../../../model'; +import { validateRoleMappingRules } from '../../services/role_mapping_validation'; +import { documentationLinks } from '../../../services/documentation_links'; + +interface Props { + rawRules: RoleMapping['rules']; + onChange: (rawRules: RoleMapping['rules']) => void; + onValidityChange: (isValid: boolean) => void; + validateForm: boolean; +} + +interface State { + rules: Rule | null; + maxDepth: number; + isRuleValid: boolean; + showConfirmModeChange: boolean; + showVisualEditorDisabledAlert: boolean; + mode: 'visual' | 'json'; +} + +export class RuleEditorPanel extends Component { + constructor(props: Props) { + super(props); + this.state = { + ...this.initializeFromRawRules(props.rawRules), + isRuleValid: true, + showConfirmModeChange: false, + showVisualEditorDisabledAlert: false, + }; + } + + public render() { + const validationResult = + this.props.validateForm && + validateRoleMappingRules({ rules: this.state.rules ? this.state.rules.toRaw() : {} }); + + let validationWarning = null; + if (validationResult && validationResult.error) { + validationWarning = ( + + + + ); + } + + return ( + + +

+ +

+
+ + + +

+ + + + ), + }} + /> +

+
+
+ + + + + {validationWarning} + {this.getEditor()} + + {this.getModeToggle()} + {this.getConfirmModeChangePrompt()} + + + + +
+
+ ); + } + + private initializeFromRawRules = (rawRules: Props['rawRules']) => { + const { rules, maxDepth } = generateRulesFromRaw(rawRules); + const mode: State['mode'] = maxDepth >= VISUAL_MAX_RULE_DEPTH ? 'json' : 'visual'; + return { + rules, + mode, + maxDepth, + }; + }; + + private getModeToggle() { + if (this.state.mode === 'json' && this.state.maxDepth > VISUAL_MAX_RULE_DEPTH) { + return ( + + + + ); + } + + // Don't offer swith if no rules are present yet + if (this.state.mode === 'visual' && this.state.rules === null) { + return null; + } + + switch (this.state.mode) { + case 'visual': + return ( + { + this.trySwitchEditorMode('json'); + }} + > + + {' '} + + + + ); + case 'json': + return ( + { + this.trySwitchEditorMode('visual'); + }} + > + + {' '} + + + + ); + default: + throw new Error(`Unexpected rule editor mode: ${this.state.mode}`); + } + } + + private getEditor() { + switch (this.state.mode) { + case 'visual': + return ( + this.trySwitchEditorMode('json')} + /> + ); + case 'json': + return ( + + ); + default: + throw new Error(`Unexpected rule editor mode: ${this.state.mode}`); + } + } + + private getConfirmModeChangePrompt = () => { + if (!this.state.showConfirmModeChange) { + return null; + } + return ( + + + } + onCancel={() => this.setState({ showConfirmModeChange: false })} + onConfirm={() => { + this.setState({ mode: 'visual', showConfirmModeChange: false }); + this.onValidityChange(true); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

+ +

+
+
+ ); + }; + + private onRuleChange = (updatedRule: Rule | null) => { + const raw = updatedRule ? updatedRule.toRaw() : {}; + this.props.onChange(raw); + this.setState({ + ...generateRulesFromRaw(raw), + }); + }; + + private onValidityChange = (isRuleValid: boolean) => { + this.setState({ isRuleValid }); + this.props.onValidityChange(isRuleValid); + }; + + private trySwitchEditorMode = (newMode: State['mode']) => { + switch (newMode) { + case 'visual': { + if (this.state.isRuleValid) { + this.setState({ mode: newMode }); + this.onValidityChange(true); + } else { + this.setState({ showConfirmModeChange: true }); + } + break; + } + case 'json': + this.setState({ mode: newMode }); + this.onValidityChange(true); + break; + default: + throw new Error(`Unexpected rule editor mode: ${this.state.mode}`); + } + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx new file mode 100644 index 00000000000000..3e0e0e386e98c2 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { RuleGroupEditor } from './rule_group_editor'; +import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { AllRule, FieldRule, AnyRule, ExceptAnyRule } from '../../../model'; +import { FieldRuleEditor } from './field_rule_editor'; +import { AddRuleButton } from './add_rule_button'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +describe('RuleGroupEditor', () => { + it('renders an empty group', () => { + const props = { + rule: new AllRule([]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = shallowWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(0); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(0); + expect(wrapper.find(AddRuleButton)).toHaveLength(1); + }); + + it('allows the group type to be changed, maintaining child rules', async () => { + const props = { + rule: new AllRule([new FieldRule('username', '*')]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(1); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(1); + expect(wrapper.find(AddRuleButton)).toHaveLength(1); + expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(1); + + const anyRule = new AnyRule(); + + findTestSubject(wrapper, 'ruleGroupTitle').simulate('click'); + await nextTick(); + wrapper.update(); + + const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => { + return menuItem.text() === anyRule.getDisplayTitle(); + }); + + anyRuleOption.simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule).toBeInstanceOf(AnyRule); + expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw()); + }); + + it('warns when changing group types which would invalidate child rules', async () => { + const props = { + rule: new AllRule([new ExceptAnyRule([new FieldRule('my_custom_field', 'foo*')])]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(2); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(1); + expect(wrapper.find(AddRuleButton)).toHaveLength(2); + expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2); + + const anyRule = new AnyRule(); + + findTestSubject(wrapper, 'ruleGroupTitle') + .first() + .simulate('click'); + await nextTick(); + wrapper.update(); + + const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => { + return menuItem.text() === anyRule.getDisplayTitle(); + }); + + anyRuleOption.simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule).toBeInstanceOf(AnyRule); + + // new rule should a defaulted field sub rule, as the existing rules are not valid for the new type + expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw()); + }); + + it('does not change groups when canceling the confirmation', async () => { + const props = { + rule: new AllRule([new ExceptAnyRule([new FieldRule('username', '*')])]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(2); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(1); + expect(wrapper.find(AddRuleButton)).toHaveLength(2); + expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2); + + const anyRule = new AnyRule(); + + findTestSubject(wrapper, 'ruleGroupTitle') + .first() + .simulate('click'); + await nextTick(); + wrapper.update(); + + const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => { + return menuItem.text() === anyRule.getDisplayTitle(); + }); + + anyRuleOption.simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + }); + + it('hides the add rule button when instructed to', () => { + const props = { + rule: new AllRule([]), + allowAdd: false, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = shallowWithIntl(); + expect(wrapper.find(AddRuleButton)).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx new file mode 100644 index 00000000000000..6fb33db179e8a6 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AddRuleButton } from './add_rule_button'; +import { RuleGroupTitle } from './rule_group_title'; +import { FieldRuleEditor } from './field_rule_editor'; +import { RuleGroup, Rule, FieldRule } from '../../../model'; +import { isRuleGroup } from '../../services/is_rule_group'; + +interface Props { + rule: RuleGroup; + allowAdd: boolean; + parentRule?: RuleGroup; + ruleDepth: number; + onChange: (rule: RuleGroup) => void; + onDelete: () => void; +} +export class RuleGroupEditor extends Component { + public render() { + return ( + + + + + + + + + + + + + + + {this.renderSubRules()} + {this.props.allowAdd && ( + + + + )} + + + ); + } + + private renderSubRules = () => { + return this.props.rule.getRules().map((subRule, subRuleIndex, rules) => { + const isLastRule = subRuleIndex === rules.length - 1; + const divider = isLastRule ? null : ( + + + + ); + + if (isRuleGroup(subRule)) { + return ( + + + { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.replaceRule(subRuleIndex, updatedSubRule); + this.props.onChange(updatedRule); + }} + onDelete={() => { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.removeRule(subRuleIndex); + this.props.onChange(updatedRule); + }} + /> + + {divider} + + ); + } + + return ( + + + { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.replaceRule(subRuleIndex, updatedSubRule); + this.props.onChange(updatedRule); + }} + onDelete={() => { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.removeRule(subRuleIndex); + this.props.onChange(updatedRule); + }} + /> + + {divider} + + ); + }); + }; + + private onAddRuleClick = (newRule: Rule) => { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.addRule(newRule); + this.props.onChange(updatedRule); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx new file mode 100644 index 00000000000000..e46893afd4d86e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiLink, + EuiIcon, + EuiOverlayMask, + EuiConfirmModal, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + RuleGroup, + AllRule, + AnyRule, + ExceptAllRule, + ExceptAnyRule, + FieldRule, +} from '../../../model'; + +interface Props { + rule: RuleGroup; + readonly?: boolean; + parentRule?: RuleGroup; + onChange: (rule: RuleGroup) => void; +} + +const rules = [new AllRule(), new AnyRule()]; +const exceptRules = [new ExceptAllRule(), new ExceptAnyRule()]; + +export const RuleGroupTitle = (props: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const [showConfirmChangeModal, setShowConfirmChangeModal] = useState(false); + const [pendingNewRule, setPendingNewRule] = useState(null); + + const canUseExcept = props.parentRule && props.parentRule.canContainRules(exceptRules); + + const availableRuleTypes = [...rules, ...(canUseExcept ? exceptRules : [])]; + + const onChange = (newRule: RuleGroup) => { + const currentSubRules = props.rule.getRules(); + const areSubRulesValid = newRule.canContainRules(currentSubRules); + if (areSubRulesValid) { + const clone = newRule.clone() as RuleGroup; + currentSubRules.forEach(subRule => clone.addRule(subRule)); + + props.onChange(clone); + setIsMenuOpen(false); + } else { + setPendingNewRule(newRule); + setShowConfirmChangeModal(true); + } + }; + + const changeRuleDiscardingSubRules = (newRule: RuleGroup) => { + // Ensure a default sub rule is present when not carrying over the original sub rules + const newRuleInstance = newRule.clone() as RuleGroup; + if (newRuleInstance.getRules().length === 0) { + newRuleInstance.addRule(new FieldRule('username', '*')); + } + + props.onChange(newRuleInstance); + setIsMenuOpen(false); + }; + + const ruleButton = ( + setIsMenuOpen(!isMenuOpen)} data-test-subj="ruleGroupTitle"> + {props.rule.getDisplayTitle()} + + ); + + const ruleTypeSelector = ( + setIsMenuOpen(false)}> + { + const isSelected = rt.getDisplayTitle() === props.rule.getDisplayTitle(); + const icon = isSelected ? 'check' : 'empty'; + return ( + onChange(rt as RuleGroup)}> + {rt.getDisplayTitle()} + + ); + })} + /> + + ); + + const confirmChangeModal = showConfirmChangeModal ? ( + + + } + onCancel={() => { + setShowConfirmChangeModal(false); + setPendingNewRule(null); + }} + onConfirm={() => { + setShowConfirmChangeModal(false); + changeRuleDiscardingSubRules(pendingNewRule!); + setPendingNewRule(null); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

+ +

+
+
+ ) : null; + + return ( +

+ {ruleTypeSelector} + {confirmChangeModal} +

+ ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx new file mode 100644 index 00000000000000..7c63613ee1cc9e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { VisualRuleEditor } from './visual_rule_editor'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { AnyRule, AllRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model'; +import { RuleGroupEditor } from './rule_group_editor'; +import { FieldRuleEditor } from './field_rule_editor'; + +describe('VisualRuleEditor', () => { + it('renders an empty prompt when no rules are defined', () => { + const props = { + rules: null, + maxDepth: 0, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule.toRaw()).toEqual({ + all: [{ field: { username: '*' } }], + }); + }); + + it('adds a rule group when the "Add rules" button is clicked', () => { + const props = { + rules: null, + maxDepth: 0, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingsNoRulesDefined')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0); + }); + + it('clicking the add button when no rules are defined populates an initial rule set', () => { + const props = { + rules: null, + maxDepth: 0, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule).toBeInstanceOf(AllRule); + expect(newRule.toRaw()).toEqual({ + all: [ + { + field: { + username: '*', + }, + }, + ], + }); + }); + + it('renders a nested rule set', () => { + const props = { + rules: new AllRule([ + new AnyRule([new FieldRule('username', '*')]), + new ExceptAnyRule([ + new FieldRule('metadata.foo.bar', '*'), + new AllRule([new FieldRule('realm', 'special-one')]), + ]), + new ExceptAllRule([new FieldRule('realm', '*')]), + ]), + maxDepth: 4, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + expect(wrapper.find(RuleGroupEditor)).toHaveLength(5); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(4); + expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0); + }); + + it('warns when the rule set is too complex', () => { + const props = { + rules: new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AnyRule([ + new AllRule([new AnyRule([new FieldRule('username', '*')])]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + maxDepth: 11, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx new file mode 100644 index 00000000000000..214c583189fb80 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { EuiEmptyPrompt, EuiCallOut, EuiSpacer, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { FieldRuleEditor } from './field_rule_editor'; +import { RuleGroupEditor } from './rule_group_editor'; +import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants'; +import { Rule, FieldRule, RuleGroup, AllRule } from '../../../model'; +import { isRuleGroup } from '../../services/is_rule_group'; + +interface Props { + rules: Rule | null; + maxDepth: number; + onChange: (rules: Rule | null) => void; + onSwitchEditorMode: () => void; +} + +export class VisualRuleEditor extends Component { + public render() { + if (this.props.rules) { + const rules = this.renderRule(this.props.rules, this.onRuleChange); + return ( + + {this.getRuleDepthWarning()} + {rules} + + ); + } + + return ( + + + + } + titleSize="s" + body={ +
+ +
+ } + data-test-subj="roleMappingsNoRulesDefined" + actions={ + { + this.props.onChange(new AllRule([new FieldRule('username', '*')])); + }} + > + + + } + /> + ); + } + + private canUseVisualEditor = () => this.props.maxDepth < VISUAL_MAX_RULE_DEPTH; + + private getRuleDepthWarning = () => { + if (this.canUseVisualEditor()) { + return null; + } + return ( + + + } + data-test-subj="roleMappingsRulesTooComplex" + > +

+ +

+ + + + +
+ +
+ ); + }; + + private onRuleChange = (updatedRule: Rule) => { + this.props.onChange(updatedRule); + }; + + private onRuleDelete = () => { + this.props.onChange(null); + }; + + private renderRule = (rule: Rule, onChange: (updatedRule: Rule) => void) => { + return this.getEditorForRuleType(rule, onChange); + }; + + private getEditorForRuleType(rule: Rule, onChange: (updatedRule: Rule) => void) { + if (isRuleGroup(rule)) { + return ( + onChange(value)} + onDelete={this.onRuleDelete} + /> + ); + } + return ( + onChange(value)} + onDelete={this.onRuleDelete} + /> + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html new file mode 100644 index 00000000000000..ca8ab9c35c49ba --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx new file mode 100644 index 00000000000000..b064a4dc50a228 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import routes from 'ui/routes'; +import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; +import { RoleMappingsAPI } from '../../../../lib/role_mappings_api'; +// @ts-ignore +import template from './edit_role_mapping.html'; +import { CREATE_ROLE_MAPPING_PATH } from '../../management_urls'; +import { getEditRoleMappingBreadcrumbs } from '../../breadcrumbs'; +import { EditRoleMappingPage } from './components'; + +routes.when(`${CREATE_ROLE_MAPPING_PATH}/:name?`, { + template, + k7Breadcrumbs: getEditRoleMappingBreadcrumbs, + controller($scope, $route) { + $scope.$$postDigest(() => { + const domNode = document.getElementById('editRoleMappingReactRoot'); + + const { name } = $route.current.params; + + render( + + + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); diff --git a/x-pack/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts similarity index 54% rename from x-pack/typings/encode_uri_query.d.ts rename to x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts index e1ab5f4a70abfe..60a879c6c29dfd 100644 --- a/x-pack/typings/encode_uri_query.d.ts +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; +import { Rule, FieldRule } from '../../model'; + +export function isRuleGroup(rule: Rule) { + return !(rule instanceof FieldRule); } diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts new file mode 100644 index 00000000000000..28010013c9f4ff --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const VISUAL_MAX_RULE_DEPTH = 5; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts new file mode 100644 index 00000000000000..9614c4338b631f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + validateRoleMappingName, + validateRoleMappingRoles, + validateRoleMappingRoleTemplates, + validateRoleMappingRules, + validateRoleMappingForSave, +} from './role_mapping_validation'; +import { RoleMapping } from '../../../../../../common/model'; + +describe('validateRoleMappingName', () => { + it('requires a value', () => { + expect(validateRoleMappingName({ name: '' } as RoleMapping)).toMatchInlineSnapshot(` + Object { + "error": "Name is required.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingRoles', () => { + it('requires a value', () => { + expect(validateRoleMappingRoles(({ roles: [] } as unknown) as RoleMapping)) + .toMatchInlineSnapshot(` + Object { + "error": "At least one role is required.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingRoleTemplates', () => { + it('requires a value', () => { + expect(validateRoleMappingRoleTemplates(({ role_templates: [] } as unknown) as RoleMapping)) + .toMatchInlineSnapshot(` + Object { + "error": "At least one role template is required.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingRules', () => { + it('requires at least one rule', () => { + expect(validateRoleMappingRules({ rules: {} } as RoleMapping)).toMatchInlineSnapshot(` + Object { + "error": "At least one rule is required.", + "isInvalid": true, + } + `); + }); + + // more exhaustive testing is done in other unit tests + it('requires rules to be valid', () => { + expect(validateRoleMappingRules(({ rules: { something: [] } } as unknown) as RoleMapping)) + .toMatchInlineSnapshot(` + Object { + "error": "Unknown rule type: something.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingForSave', () => { + it('fails if the role mapping is missing a name', () => { + expect( + validateRoleMappingForSave(({ + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "error": "Name is required.", + "isInvalid": true, + } + `); + }); + + it('fails if the role mapping is missing rules', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: ['superuser'], + rules: {}, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "error": "At least one rule is required.", + "isInvalid": true, + } + `); + }); + + it('fails if the role mapping is missing both roles and templates', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: [], + role_templates: [], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "error": "At least one role is required.", + "isInvalid": true, + } + `); + }); + + it('validates a correct role mapping using role templates', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: [], + role_templates: [{ template: { id: 'foo' } }], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "isInvalid": false, + } + `); + }); + + it('validates a correct role mapping using roles', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "isInvalid": false, + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts new file mode 100644 index 00000000000000..5916d6fd9e1891 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../common/model'; +import { generateRulesFromRaw } from '../../model'; + +interface ValidationResult { + isInvalid: boolean; + error?: string; +} + +export function validateRoleMappingName({ name }: RoleMapping): ValidationResult { + if (!name) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidName', { + defaultMessage: 'Name is required.', + }) + ); + } + return valid(); +} + +export function validateRoleMappingRoles({ roles }: RoleMapping): ValidationResult { + if (roles && !roles.length) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidRoles', { + defaultMessage: 'At least one role is required.', + }) + ); + } + return valid(); +} + +export function validateRoleMappingRoleTemplates({ + role_templates: roleTemplates, +}: RoleMapping): ValidationResult { + if (roleTemplates && !roleTemplates.length) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidRoleTemplates', { + defaultMessage: 'At least one role template is required.', + }) + ); + } + return valid(); +} + +export function validateRoleMappingRules({ rules }: Pick): ValidationResult { + try { + const { rules: parsedRules } = generateRulesFromRaw(rules); + if (!parsedRules) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidRoleRule', { + defaultMessage: 'At least one rule is required.', + }) + ); + } + } catch (e) { + return invalid(e.message); + } + + return valid(); +} + +export function validateRoleMappingForSave(roleMapping: RoleMapping): ValidationResult { + const { isInvalid: isNameInvalid, error: nameError } = validateRoleMappingName(roleMapping); + const { isInvalid: areRolesInvalid, error: rolesError } = validateRoleMappingRoles(roleMapping); + const { + isInvalid: areRoleTemplatesInvalid, + error: roleTemplatesError, + } = validateRoleMappingRoleTemplates(roleMapping); + + const { isInvalid: areRulesInvalid, error: rulesError } = validateRoleMappingRules(roleMapping); + + const canSave = + !isNameInvalid && (!areRolesInvalid || !areRoleTemplatesInvalid) && !areRulesInvalid; + + if (canSave) { + return valid(); + } + return invalid(nameError || rulesError || rolesError || roleTemplatesError); +} + +function valid() { + return { isInvalid: false }; +} + +function invalid(error?: string) { + return { isInvalid: true, error }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts new file mode 100644 index 00000000000000..8e1f47a4157ae9 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + isStoredRoleTemplate, + isInlineRoleTemplate, + isInvalidRoleTemplate, +} from './role_template_type'; +import { RoleTemplate } from '../../../../../../common/model'; + +describe('#isStoredRoleTemplate', () => { + it('returns true for stored templates, false otherwise', () => { + expect(isStoredRoleTemplate({ template: { id: '' } })).toEqual(true); + expect(isStoredRoleTemplate({ template: { source: '' } })).toEqual(false); + expect(isStoredRoleTemplate({ template: 'asdf' })).toEqual(false); + expect(isStoredRoleTemplate({} as RoleTemplate)).toEqual(false); + }); +}); + +describe('#isInlineRoleTemplate', () => { + it('returns true for inline templates, false otherwise', () => { + expect(isInlineRoleTemplate({ template: { source: '' } })).toEqual(true); + expect(isInlineRoleTemplate({ template: { id: '' } })).toEqual(false); + expect(isInlineRoleTemplate({ template: 'asdf' })).toEqual(false); + expect(isInlineRoleTemplate({} as RoleTemplate)).toEqual(false); + }); +}); + +describe('#isInvalidRoleTemplate', () => { + it('returns true for invalid templates, false otherwise', () => { + expect(isInvalidRoleTemplate({ template: 'asdf' })).toEqual(true); + expect(isInvalidRoleTemplate({} as RoleTemplate)).toEqual(true); + expect(isInvalidRoleTemplate({ template: { source: '' } })).toEqual(false); + expect(isInvalidRoleTemplate({ template: { id: '' } })).toEqual(false); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts new file mode 100644 index 00000000000000..90d8d1a09e5877 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + RoleTemplate, + StoredRoleTemplate, + InlineRoleTemplate, + InvalidRoleTemplate, +} from '../../../../../../common/model'; + +export function isStoredRoleTemplate( + roleMappingTemplate: RoleTemplate +): roleMappingTemplate is StoredRoleTemplate { + return ( + roleMappingTemplate.template != null && + roleMappingTemplate.template.hasOwnProperty('id') && + typeof ((roleMappingTemplate as unknown) as StoredRoleTemplate).template.id === 'string' + ); +} + +export function isInlineRoleTemplate( + roleMappingTemplate: RoleTemplate +): roleMappingTemplate is InlineRoleTemplate { + return ( + roleMappingTemplate.template != null && + roleMappingTemplate.template.hasOwnProperty('source') && + typeof ((roleMappingTemplate as unknown) as InlineRoleTemplate).template.source === 'string' + ); +} + +export function isInvalidRoleTemplate( + roleMappingTemplate: RoleTemplate +): roleMappingTemplate is InvalidRoleTemplate { + return !isStoredRoleTemplate(roleMappingTemplate) && !isInlineRoleTemplate(roleMappingTemplate); +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap new file mode 100644 index 00000000000000..1c61383b951ae5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateRulesFromRaw "field" does not support a value of () => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found function ()."`; + +exports[`generateRulesFromRaw "field" does not support a value of [object Object] 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ({})."`; + +exports[`generateRulesFromRaw "field" does not support a value of [object Object],,,() => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ([{},null,[],null])."`; + +exports[`generateRulesFromRaw "field" does not support a value of undefined 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found undefined ()."`; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts new file mode 100644 index 00000000000000..ddf3b4499f73b8 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('All rule', () => { + it('can be constructed without sub rules', () => { + const rule = new AllRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new AllRule([new AnyRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept rules of any type', () => { + const subRules = [ + new AllRule(), + new AnyRule(), + new FieldRule('username', '*'), + new ExceptAllRule(), + new ExceptAnyRule(), + ]; + + const rule = new AllRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('can replace an existing rule', () => { + const rule = new AllRule([new AnyRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new AllRule([new AnyRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new AllRule([new AnyRule()]); + expect(rule.toRaw()).toEqual({ + all: [{ any: [] }], + }); + }); + + it('can clone itself', () => { + const subRules = [new AnyRule()]; + const rule = new AllRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts new file mode 100644 index 00000000000000..ecea27a7fb87fb --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; + +/** + * Represents a group of rules which must all evaluate to true. + */ +export class AllRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.allRule.displayTitle', { + defaultMessage: 'All are true', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules() { + return true; + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new AllRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + return { + all: [...this.rules.map(rule => rule.toRaw())], + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts new file mode 100644 index 00000000000000..767aa075755afa --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('Any rule', () => { + it('can be constructed without sub rules', () => { + const rule = new AnyRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new AnyRule([new AllRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept non-except rules', () => { + const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')]; + + const rule = new AnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('cannot accept except rules', () => { + const subRules = [new ExceptAllRule(), new ExceptAnyRule()]; + + const rule = new AnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(false); + }); + + it('can replace an existing rule', () => { + const rule = new AnyRule([new AllRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new AnyRule([new AllRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new AnyRule([new AllRule()]); + expect(rule.toRaw()).toEqual({ + any: [{ all: [] }], + }); + }); + + it('can clone itself', () => { + const subRules = [new AllRule()]; + const rule = new AnyRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts new file mode 100644 index 00000000000000..6a4f2eaf1b3628 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; +import { ExceptAllRule } from './except_all_rule'; +import { ExceptAnyRule } from './except_any_rule'; + +/** + * Represents a group of rules in which at least one must evaluate to true. + */ +export class AnyRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.anyRule.displayTitle', { + defaultMessage: 'Any are true', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules(rules: Rule[]) { + const forbiddenRules = [ExceptAllRule, ExceptAnyRule]; + return rules.every( + candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden) + ); + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new AnyRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + return { + any: [...this.rules.map(rule => rule.toRaw())], + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts new file mode 100644 index 00000000000000..7a00e5b19638fa --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('Except All rule', () => { + it('can be constructed without sub rules', () => { + const rule = new ExceptAllRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new ExceptAllRule([new AnyRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept rules of any type', () => { + const subRules = [ + new AllRule(), + new AnyRule(), + new FieldRule('username', '*'), + new ExceptAllRule(), + new ExceptAnyRule(), + ]; + + const rule = new ExceptAllRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('can replace an existing rule', () => { + const rule = new ExceptAllRule([new AnyRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new ExceptAllRule([new AnyRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new ExceptAllRule([new AnyRule()]); + expect(rule.toRaw()).toEqual({ + except: { all: [{ any: [] }] }, + }); + }); + + it('can clone itself', () => { + const subRules = [new AllRule()]; + const rule = new ExceptAllRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts new file mode 100644 index 00000000000000..a67c2622a25337 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; + +/** + * Represents a group of rules in which at least one must evaluate to false. + */ +export class ExceptAllRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.exceptAllRule.displayTitle', { + defaultMessage: 'Any are false', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules() { + return true; + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new ExceptAllRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + const rawRule = { + all: [...this.rules.map(rule => rule.toRaw())], + }; + + return { + except: rawRule, + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts new file mode 100644 index 00000000000000..e4e182ce88d8d9 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('Except Any rule', () => { + it('can be constructed without sub rules', () => { + const rule = new ExceptAnyRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new ExceptAnyRule([new AllRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept non-except rules', () => { + const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')]; + + const rule = new ExceptAnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('cannot accept except rules', () => { + const subRules = [new ExceptAllRule(), new ExceptAnyRule()]; + + const rule = new ExceptAnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(false); + }); + + it('can replace an existing rule', () => { + const rule = new ExceptAnyRule([new AllRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new ExceptAnyRule([new AllRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new ExceptAnyRule([new AllRule()]); + expect(rule.toRaw()).toEqual({ + except: { any: [{ all: [] }] }, + }); + }); + + it('can clone itself', () => { + const subRules = [new AllRule()]; + const rule = new ExceptAnyRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts new file mode 100644 index 00000000000000..12ec3fe85b80da --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; +import { ExceptAllRule } from './except_all_rule'; + +/** + * Represents a group of rules in which none can evaluate to true (all must evaluate to false). + */ +export class ExceptAnyRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.exceptAnyRule.displayTitle', { + defaultMessage: 'All are false', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules(rules: Rule[]) { + const forbiddenRules = [ExceptAllRule, ExceptAnyRule]; + return rules.every( + candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden) + ); + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new ExceptAnyRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + const rawRule = { + any: [...this.rules.map(rule => rule.toRaw())], + }; + + return { + except: rawRule, + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts new file mode 100644 index 00000000000000..3447e817070026 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FieldRule } from '.'; + +describe('FieldRule', () => { + ['*', 1, null, true, false].forEach(value => { + it(`can convert itself to raw form with a single value of ${value}`, () => { + const rule = new FieldRule('username', value); + expect(rule.toRaw()).toEqual({ + field: { + username: value, + }, + }); + }); + }); + + it('can convert itself to raw form with an array of values', () => { + const values = ['*', 1, null, true, false]; + const rule = new FieldRule('username', values); + const raw = rule.toRaw(); + expect(raw).toEqual({ + field: { + username: ['*', 1, null, true, false], + }, + }); + + // shoud not be the same array instance + expect(raw.field.username).not.toBe(values); + }); + + it('can clone itself', () => { + const values = ['*', 1, null]; + const rule = new FieldRule('username', values); + + const clone = rule.clone(); + expect(clone.field).toEqual(rule.field); + expect(clone.value).toEqual(rule.value); + expect(clone.value).not.toBe(rule.value); + expect(clone.toRaw()).toEqual(rule.toRaw()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts new file mode 100644 index 00000000000000..3e6a0e1e7ecb39 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { Rule } from './rule'; + +/** The allowed types for field rule values */ +export type FieldRuleValue = + | string + | number + | null + | boolean + | Array; + +/** + * Represents a single field rule. + * Ex: "username = 'foo'" + */ +export class FieldRule extends Rule { + constructor(public readonly field: string, public readonly value: FieldRuleValue) { + super(); + } + + /** {@see Rule.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.fieldRule.displayTitle', { + defaultMessage: 'The following is true', + }); + } + + /** {@see Rule.clone} */ + public clone() { + return new FieldRule(this.field, Array.isArray(this.value) ? [...this.value] : this.value); + } + + /** {@see Rule.toRaw} */ + public toRaw() { + return { + field: { + [this.field]: Array.isArray(this.value) ? [...this.value] : this.value, + }, + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts new file mode 100644 index 00000000000000..cbc970f02b03ea --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AllRule } from './all_rule'; +export { AnyRule } from './any_rule'; +export { Rule } from './rule'; +export { RuleGroup } from './rule_group'; +export { ExceptAllRule } from './except_all_rule'; +export { ExceptAnyRule } from './except_any_rule'; +export { FieldRule, FieldRuleValue } from './field_rule'; +export { generateRulesFromRaw } from './rule_builder'; +export { RuleBuilderError } from './rule_builder_error'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts new file mode 100644 index 00000000000000..5cab2f1736e949 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Represents a Role Mapping rule. + */ +export abstract class Rule { + /** + * Converts this rule into a raw object for use in the persisted Role Mapping. + */ + abstract toRaw(): Record; + + /** + * The display title for this rule. + */ + abstract getDisplayTitle(): string; + + /** + * Returns a new instance of this rule. + */ + abstract clone(): Rule; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts new file mode 100644 index 00000000000000..ebd48f6d15d99e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generateRulesFromRaw, FieldRule } from '.'; +import { RoleMapping } from '../../../../../common/model'; +import { RuleBuilderError } from './rule_builder_error'; + +describe('generateRulesFromRaw', () => { + it('returns null for an empty rule set', () => { + expect(generateRulesFromRaw({})).toEqual({ + rules: null, + maxDepth: 0, + }); + }); + + it('returns a correctly parsed rule set', () => { + const rawRules: RoleMapping['rules'] = { + all: [ + { + except: { + all: [ + { + field: { username: '*' }, + }, + ], + }, + }, + { + any: [ + { + field: { dn: '*' }, + }, + ], + }, + ], + }; + + const { rules, maxDepth } = generateRulesFromRaw(rawRules); + + expect(rules).toMatchInlineSnapshot(` + AllRule { + "rules": Array [ + ExceptAllRule { + "rules": Array [ + FieldRule { + "field": "username", + "value": "*", + }, + ], + }, + AnyRule { + "rules": Array [ + FieldRule { + "field": "dn", + "value": "*", + }, + ], + }, + ], + } + `); + expect(maxDepth).toEqual(3); + }); + + it('does not support multiple rules at the root level', () => { + expect(() => { + generateRulesFromRaw({ + all: [ + { + field: { username: '*' }, + }, + ], + any: [ + { + field: { username: '*' }, + }, + ], + }); + }).toThrowError('Expected a single rule definition, but found 2.'); + }); + + it('provides a rule trace describing the location of the error', () => { + try { + generateRulesFromRaw({ + all: [ + { + field: { username: '*' }, + }, + { + any: [ + { + field: { username: '*' }, + }, + { + except: { field: { username: '*' } }, + }, + ], + }, + ], + }); + throw new Error(`Expected generateRulesFromRaw to throw error.`); + } catch (e) { + if (e instanceof RuleBuilderError) { + expect(e.message).toEqual(`"except" rule can only exist within an "all" rule.`); + expect(e.ruleTrace).toEqual(['all', '[1]', 'any', '[1]', 'except']); + } else { + throw e; + } + } + }); + + it('calculates the max depth of the rule tree', () => { + const rules = { + all: [ + // depth = 1 + { + // depth = 2 + all: [ + // depth = 3 + { + any: [ + // depth == 4 + { field: { username: 'foo' } }, + ], + }, + { except: { field: { username: 'foo' } } }, + ], + }, + { + // depth = 2 + any: [ + { + // depth = 3 + all: [ + { + // depth = 4 + any: [ + { + // depth = 5 + all: [ + { + // depth = 6 + all: [ + // depth = 7 + { + except: { + field: { username: 'foo' }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + expect(generateRulesFromRaw(rules).maxDepth).toEqual(7); + }); + + describe('"any"', () => { + it('expects an array value', () => { + expect(() => { + generateRulesFromRaw({ + any: { + field: { username: '*' }, + } as any, + }); + }).toThrowError('Expected an array of rules, but found object.'); + }); + + it('expects each entry to be an object with a single property', () => { + expect(() => { + generateRulesFromRaw({ + any: [ + { + any: [{ field: { foo: 'bar' } }], + all: [{ field: { foo: 'bar' } }], + } as any, + ], + }); + }).toThrowError('Expected a single rule definition, but found 2.'); + }); + }); + + describe('"all"', () => { + it('expects an array value', () => { + expect(() => { + generateRulesFromRaw({ + all: { + field: { username: '*' }, + } as any, + }); + }).toThrowError('Expected an array of rules, but found object.'); + }); + + it('expects each entry to be an object with a single property', () => { + expect(() => { + generateRulesFromRaw({ + all: [ + { + field: { username: '*' }, + any: [{ field: { foo: 'bar' } }], + } as any, + ], + }); + }).toThrowError('Expected a single rule definition, but found 2.'); + }); + }); + + describe('"field"', () => { + it(`expects an object value`, () => { + expect(() => { + generateRulesFromRaw({ + field: [ + { + username: '*', + }, + ], + }); + }).toThrowError('Expected an object, but found array.'); + }); + + it(`expects an single property in its object value`, () => { + expect(() => { + generateRulesFromRaw({ + field: { + username: '*', + dn: '*', + }, + }); + }).toThrowError('Expected a single field, but found 2.'); + }); + + it('accepts an array of possible values', () => { + const { rules } = generateRulesFromRaw({ + field: { + username: [0, '*', null, 'foo', true, false], + }, + }); + + expect(rules).toBeInstanceOf(FieldRule); + expect((rules as FieldRule).field).toEqual('username'); + expect((rules as FieldRule).value).toEqual([0, '*', null, 'foo', true, false]); + }); + + [{}, () => null, undefined, [{}, undefined, [], () => null]].forEach(invalidValue => { + it(`does not support a value of ${invalidValue}`, () => { + expect(() => { + generateRulesFromRaw({ + field: { + username: invalidValue, + }, + }); + }).toThrowErrorMatchingSnapshot(); + }); + }); + }); + + describe('"except"', () => { + it(`expects an object value`, () => { + expect(() => { + generateRulesFromRaw({ + all: [ + { + except: [ + { + field: { username: '*' }, + }, + ], + }, + ], + } as any); + }).toThrowError('Expected an object, but found array.'); + }); + + it(`can only be nested inside an "all" clause`, () => { + expect(() => { + generateRulesFromRaw({ + any: [ + { + except: { + field: { + username: '*', + }, + }, + }, + ], + }); + }).toThrowError(`"except" rule can only exist within an "all" rule.`); + + expect(() => { + generateRulesFromRaw({ + except: { + field: { + username: '*', + }, + }, + }); + }).toThrowError(`"except" rule can only exist within an "all" rule.`); + }); + + it('converts an "except field" rule into an equivilent "except all" rule', () => { + expect( + generateRulesFromRaw({ + all: [ + { + except: { + field: { + username: '*', + }, + }, + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "maxDepth": 2, + "rules": AllRule { + "rules": Array [ + ExceptAllRule { + "rules": Array [ + FieldRule { + "field": "username", + "value": "*", + }, + ], + }, + ], + }, + } + `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts new file mode 100644 index 00000000000000..fe344b2ae38dd0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../common/model'; +import { FieldRule, FieldRuleValue } from './field_rule'; +import { AllRule } from './all_rule'; +import { AnyRule } from './any_rule'; +import { Rule } from './rule'; +import { ExceptAllRule } from './except_all_rule'; +import { ExceptAnyRule } from './except_any_rule'; +import { RuleBuilderError } from '.'; + +interface RuleBuilderResult { + /** The maximum rule depth within the parsed rule set. */ + maxDepth: number; + + /** The parsed rule set. */ + rules: Rule | null; +} + +/** + * Given a set of raw rules, this constructs a class based tree for consumption by the Role Management UI. + * This also performs validation on the raw rule set, as it is possible to enter raw JSON in the JSONRuleEditor, + * so we have no guarantees that the rule set is valid ahead of time. + * + * @param rawRules the raw rules to translate. + */ +export function generateRulesFromRaw(rawRules: RoleMapping['rules'] = {}): RuleBuilderResult { + return parseRawRules(rawRules, null, [], 0); +} + +function parseRawRules( + rawRules: RoleMapping['rules'], + parentRuleType: string | null, + ruleTrace: string[], + depth: number +): RuleBuilderResult { + const entries = Object.entries(rawRules); + if (!entries.length) { + return { + rules: null, + maxDepth: 0, + }; + } + if (entries.length > 1) { + throw new RuleBuilderError( + i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectSingleRule', { + defaultMessage: `Expected a single rule definition, but found {numberOfRules}.`, + values: { numberOfRules: entries.length }, + }), + ruleTrace + ); + } + + const rule = entries[0]; + const [ruleType, ruleDefinition] = rule; + return createRuleForType(ruleType, ruleDefinition, parentRuleType, ruleTrace, depth + 1); +} + +function createRuleForType( + ruleType: string, + ruleDefinition: any, + parentRuleType: string | null, + ruleTrace: string[] = [], + depth: number +): RuleBuilderResult { + const isRuleNegated = parentRuleType === 'except'; + + const currentRuleTrace = [...ruleTrace, ruleType]; + + switch (ruleType) { + case 'field': { + assertIsObject(ruleDefinition, currentRuleTrace); + + const entries = Object.entries(ruleDefinition); + if (entries.length !== 1) { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.expectedSingleFieldRule', + { + defaultMessage: `Expected a single field, but found {count}.`, + values: { count: entries.length }, + } + ), + currentRuleTrace + ); + } + + const [field, value] = entries[0] as [string, FieldRuleValue]; + const values = Array.isArray(value) ? value : [value]; + values.forEach(fieldValue => { + const valueType = typeof fieldValue; + if (fieldValue !== null && !['string', 'number', 'boolean'].includes(valueType)) { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.invalidFieldValueType', + { + defaultMessage: `Invalid value type for field. Expected one of null, string, number, or boolean, but found {valueType} ({value}).`, + values: { valueType, value: JSON.stringify(value) }, + } + ), + currentRuleTrace + ); + } + }); + + const fieldRule = new FieldRule(field, value); + return { + rules: isRuleNegated ? new ExceptAllRule([fieldRule]) : fieldRule, + maxDepth: depth, + }; + } + case 'any': // intentional fall-through to 'all', as validation logic is identical + case 'all': { + if (ruleDefinition != null && !Array.isArray(ruleDefinition)) { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.expectedArrayForGroupRule', + { + defaultMessage: `Expected an array of rules, but found {type}.`, + values: { type: typeof ruleDefinition }, + } + ), + currentRuleTrace + ); + } + + const subRulesResults = ((ruleDefinition as any[]) || []).map((definition: any, index) => + parseRawRules(definition, ruleType, [...currentRuleTrace, `[${index}]`], depth) + ) as RuleBuilderResult[]; + + const { subRules, maxDepth } = subRulesResults.reduce( + (acc, result) => { + return { + subRules: [...acc.subRules, result.rules!], + maxDepth: Math.max(acc.maxDepth, result.maxDepth), + }; + }, + { subRules: [] as Rule[], maxDepth: 0 } + ); + + if (ruleType === 'all') { + return { + rules: isRuleNegated ? new ExceptAllRule(subRules) : new AllRule(subRules), + maxDepth, + }; + } else { + return { + rules: isRuleNegated ? new ExceptAnyRule(subRules) : new AnyRule(subRules), + maxDepth, + }; + } + } + case 'except': { + assertIsObject(ruleDefinition, currentRuleTrace); + + if (parentRuleType !== 'all') { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.exceptOnlyInAllRule', + { + defaultMessage: `"except" rule can only exist within an "all" rule.`, + } + ), + currentRuleTrace + ); + } + // subtracting 1 from depth because we don't currently count the "except" level itself as part of the depth calculation + // for the purpose of determining if the rule set is "too complex" for the visual rule editor. + // The "except" rule MUST be nested within an "all" rule type (see validation above), so the depth itself will always be a non-negative number. + return parseRawRules(ruleDefinition || {}, ruleType, currentRuleTrace, depth - 1); + } + default: + throw new RuleBuilderError( + i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.unknownRuleType', { + defaultMessage: `Unknown rule type: {ruleType}.`, + values: { ruleType }, + }), + currentRuleTrace + ); + } +} + +function assertIsObject(ruleDefinition: any, ruleTrace: string[]) { + let fieldType: string = typeof ruleDefinition; + if (Array.isArray(ruleDefinition)) { + fieldType = 'array'; + } + + if (ruleDefinition && fieldType !== 'object') { + throw new RuleBuilderError( + i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectedObjectError', { + defaultMessage: `Expected an object, but found {type}.`, + values: { type: fieldType }, + }), + ruleTrace + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts new file mode 100644 index 00000000000000..87d73cde00dd68 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.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; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Describes an error during rule building. + * In addition to a user-"friendly" message, this also includes a rule trace, + * which is the "JSON path" where the error occurred. + */ +export class RuleBuilderError extends Error { + constructor(message: string, public readonly ruleTrace: string[]) { + super(message); + + // Set the prototype explicitly, see: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, RuleBuilderError.prototype); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts new file mode 100644 index 00000000000000..3e1e7fad9b36f5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Rule } from './rule'; + +/** + * Represents a catagory of Role Mapping rules which are capable of containing other rules. + */ +export abstract class RuleGroup extends Rule { + /** + * Returns all immediate sub-rules within this group (non-recursive). + */ + abstract getRules(): Rule[]; + + /** + * Replaces the rule at the indicated location. + * @param ruleIndex the location of the rule to replace. + * @param rule the new rule. + */ + abstract replaceRule(ruleIndex: number, rule: Rule): void; + + /** + * Removes the rule at the indicated location. + * @param ruleIndex the location of the rule to remove. + */ + abstract removeRule(ruleIndex: number): void; + + /** + * Adds a rule to this group. + * @param rule the rule to add. + */ + abstract addRule(rule: Rule): void; + + /** + * Determines if the provided rules are allowed to be contained within this group. + * @param rules the rules to test. + */ + abstract canContainRules(rules: Rule[]): boolean; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx new file mode 100644 index 00000000000000..2342eeb97d03e3 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getCreateRoleMappingHref } from '../../../../management_urls'; + +export const CreateRoleMappingButton = () => { + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts new file mode 100644 index 00000000000000..417bf50d754e62 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CreateRoleMappingButton } from './create_role_mapping_button'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx new file mode 100644 index 00000000000000..1919d3fbf4785a --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateRoleMappingButton } from '../create_role_mapping_button'; + +export const EmptyPrompt: React.FunctionComponent<{}> = () => ( + + + + } + body={ + +

+ +

+
+ } + actions={} + data-test-subj="roleMappingsEmptyPrompt" + /> +); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts new file mode 100644 index 00000000000000..982e34a0ceed50 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EmptyPrompt } from './empty_prompt'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts new file mode 100644 index 00000000000000..4bd5df71da4460 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { RoleMappingsGridPage } from './role_mappings_grid_page'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx new file mode 100644 index 00000000000000..259cdc71e25a22 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { RoleMappingsGridPage } from '.'; +import { SectionLoading, PermissionDenied, NoCompatibleRealms } from '../../components'; +import { EmptyPrompt } from './empty_prompt'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { EuiLink } from '@elastic/eui'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { act } from '@testing-library/react'; + +describe('RoleMappingsGridPage', () => { + it('renders an empty prompt when no role mappings exist', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(EmptyPrompt)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + expect(wrapper.find(EmptyPrompt)).toHaveLength(1); + }); + + it('renders a permission denied message when unauthorized to manage role mappings', async () => { + const roleMappingsAPI = ({ + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: false, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(PermissionDenied)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + expect(wrapper.find(PermissionDenied)).toHaveLength(1); + }); + + it('renders a warning when there are no compatible realms enabled', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some realm', + enabled: true, + roles: [], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: false, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1); + }); + + it('renders links to mapped roles', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some realm', + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + const links = findTestSubject(wrapper, 'roleMappingRoles').find(EuiLink); + expect(links).toHaveLength(1); + expect(links.at(0).props()).toMatchObject({ + href: '#/management/security/roles/edit/superuser', + }); + }); + + it('describes the number of mapped role templates', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some realm', + enabled: true, + role_templates: [{}, {}], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + const templates = findTestSubject(wrapper, 'roleMappingRoles'); + expect(templates).toHaveLength(1); + expect(templates.text()).toEqual(`2 role templates defined`); + }); + + it('allows role mappings to be deleted, refreshing the grid after', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some-realm', + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'some-realm', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(1); + expect(roleMappingsAPI.deleteRoleMappings).not.toHaveBeenCalled(); + + findTestSubject(wrapper, `deleteRoleMappingButton-some-realm`).simulate('click'); + expect(findTestSubject(wrapper, 'deleteRoleMappingConfirmationModal')).toHaveLength(1); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['some-realm']); + // Expect an additional API call to refresh the grid + expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(2); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx new file mode 100644 index 00000000000000..7b23f2288d1ba6 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx @@ -0,0 +1,474 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiLink, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { EmptyPrompt } from './empty_prompt'; +import { + NoCompatibleRealms, + DeleteProvider, + PermissionDenied, + SectionLoading, +} from '../../components'; +import { documentationLinks } from '../../services/documentation_links'; +import { + getCreateRoleMappingHref, + getEditRoleMappingHref, + getEditRoleHref, +} from '../../../management_urls'; + +interface Props { + roleMappingsAPI: RoleMappingsAPI; +} + +interface State { + loadState: 'loadingApp' | 'loadingTable' | 'permissionDenied' | 'finished'; + roleMappings: null | RoleMapping[]; + selectedItems: RoleMapping[]; + hasCompatibleRealms: boolean; + error: any; +} + +export class RoleMappingsGridPage extends Component { + constructor(props: any) { + super(props); + this.state = { + loadState: 'loadingApp', + roleMappings: null, + hasCompatibleRealms: true, + selectedItems: [], + error: undefined, + }; + } + + public componentDidMount() { + this.checkPrivileges(); + } + + public render() { + const { loadState, error, roleMappings } = this.state; + + if (loadState === 'permissionDenied') { + return ; + } + + if (loadState === 'loadingApp') { + return ( + + + + + + ); + } + + if (error) { + const { + body: { error: errorTitle, message, statusCode }, + } = error; + + return ( + + + } + color="danger" + iconType="alert" + > + {statusCode}: {errorTitle} - {message} + + + ); + } + + if (loadState === 'finished' && roleMappings && roleMappings.length === 0) { + return ( + + + + ); + } + + return ( + + + + +

+ +

+
+ +

+ + + + ), + }} + /> +

+
+
+ + + + + +
+ + + {!this.state.hasCompatibleRealms && ( + <> + + + + )} + {this.renderTable()} + + +
+ ); + } + + private renderTable = () => { + const { roleMappings, selectedItems, loadState } = this.state; + + const message = + loadState === 'loadingTable' ? ( + + ) : ( + undefined + ); + + const sorting = { + sort: { + field: 'name', + direction: 'asc' as any, + }, + }; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50], + }; + + const selection = { + onSelectionChange: (newSelectedItems: RoleMapping[]) => { + this.setState({ + selectedItems: newSelectedItems, + }); + }, + }; + + const search = { + toolsLeft: selectedItems.length ? ( + + {deleteRoleMappingsPrompt => { + return ( + deleteRoleMappingsPrompt(selectedItems, this.onRoleMappingsDeleted)} + color="danger" + data-test-subj="bulkDeleteActionButton" + > + + + ); + }} + + ) : ( + undefined + ), + toolsRight: ( + this.reloadRoleMappings()} + data-test-subj="reloadButton" + > + + + ), + box: { + incremental: true, + }, + filters: undefined, + }; + + return ( + { + return { + 'data-test-subj': 'roleMappingRow', + }; + }} + /> + ); + }; + + private getColumnConfig = () => { + const config = [ + { + field: 'name', + name: i18n.translate('xpack.security.management.roleMappings.nameColumnName', { + defaultMessage: 'Name', + }), + sortable: true, + render: (roleMappingName: string) => { + return ( + + {roleMappingName} + + ); + }, + }, + { + field: 'roles', + name: i18n.translate('xpack.security.management.roleMappings.rolesColumnName', { + defaultMessage: 'Roles', + }), + sortable: true, + render: (entry: any, record: RoleMapping) => { + const { roles = [], role_templates: roleTemplates = [] } = record; + if (roleTemplates.length > 0) { + return ( + + {i18n.translate('xpack.security.management.roleMappings.roleTemplates', { + defaultMessage: + '{templateCount, plural, one{# role template} other {# role templates}} defined', + values: { + templateCount: roleTemplates.length, + }, + })} + + ); + } + const roleLinks = roles.map((rolename, index) => { + return ( + + {rolename} + {index === roles.length - 1 ? null : ', '} + + ); + }); + return
{roleLinks}
; + }, + }, + { + field: 'enabled', + name: i18n.translate('xpack.security.management.roleMappings.enabledColumnName', { + defaultMessage: 'Enabled', + }), + sortable: true, + render: (enabled: boolean) => { + if (enabled) { + return ( + + + + ); + } + + return ( + + + + ); + }, + }, + { + name: i18n.translate('xpack.security.management.roleMappings.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (record: RoleMapping) => { + return ( + + + + ); + }, + }, + { + render: (record: RoleMapping) => { + return ( + + + + {deleteRoleMappingPrompt => { + return ( + + + deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted) + } + /> + + ); + }} + + + + ); + }, + }, + ], + }, + ]; + return config; + }; + + private onRoleMappingsDeleted = (roleMappings: string[]): void => { + if (roleMappings.length) { + this.reloadRoleMappings(); + } + }; + + private async checkPrivileges() { + try { + const { + canManageRoleMappings, + hasCompatibleRealms, + } = await this.props.roleMappingsAPI.checkRoleMappingFeatures(); + + this.setState({ + loadState: canManageRoleMappings ? this.state.loadState : 'permissionDenied', + hasCompatibleRealms, + }); + + if (canManageRoleMappings) { + this.loadRoleMappings(); + } + } catch (e) { + this.setState({ error: e, loadState: 'finished' }); + } + } + + private reloadRoleMappings = () => { + this.setState({ roleMappings: [], loadState: 'loadingTable' }); + this.loadRoleMappings(); + }; + + private loadRoleMappings = async () => { + try { + const roleMappings = await this.props.roleMappingsAPI.getRoleMappings(); + this.setState({ roleMappings }); + } catch (e) { + this.setState({ error: e }); + } + + this.setState({ loadState: 'finished' }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx new file mode 100644 index 00000000000000..9e925d0fa6dc0a --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import routes from 'ui/routes'; +import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; +import { RoleMappingsAPI } from '../../../../lib/role_mappings_api'; +// @ts-ignore +import template from './role_mappings.html'; +import { ROLE_MAPPINGS_PATH } from '../../management_urls'; +import { getRoleMappingBreadcrumbs } from '../../breadcrumbs'; +import { RoleMappingsGridPage } from './components'; + +routes.when(ROLE_MAPPINGS_PATH, { + template, + k7Breadcrumbs: getRoleMappingBreadcrumbs, + controller($scope) { + $scope.$$postDigest(() => { + const domNode = document.getElementById('roleMappingsGridReactRoot'); + + render( + + + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html new file mode 100644 index 00000000000000..cff3b821d132c0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts new file mode 100644 index 00000000000000..36351f49890a1c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +class DocumentationLinksService { + private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; + + public getRoleMappingDocUrl() { + return `${this.esDocBasePath}/mapping-roles.html`; + } + + public getRoleMappingAPIDocUrl() { + return `${this.esDocBasePath}/security-api-put-role-mapping.html`; + } + + public getRoleMappingTemplateDocUrl() { + return `${this.esDocBasePath}/security-api-put-role-mapping.html#_role_templates`; + } + + public getRoleMappingFieldRulesDocUrl() { + return `${this.esDocBasePath}/role-mapping-resources.html#mapping-roles-rule-field`; + } +} + +export const documentationLinks = new DocumentationLinksService(); diff --git a/x-pack/legacy/plugins/siem/cypress/README.md b/x-pack/legacy/plugins/siem/cypress/README.md index fb2b6cd2e3fd30..c9e0d4e18f78f7 100644 --- a/x-pack/legacy/plugins/siem/cypress/README.md +++ b/x-pack/legacy/plugins/siem/cypress/README.md @@ -51,10 +51,23 @@ export const USERNAME = '[data-test-subj="loginUsername"]'; We prefer not to mock API responses in most of our smoke tests, but sometimes it's necessary because a test must assert that a specific value is rendered, and it's not possible to derive that value based on the data in the -envrionment where tests are running. +environment where tests are running. Mocked responses API from the server are located in `siem/cypress/fixtures`. +## Speeding up test execution time + +Loading the web page takes a big amount of time, in order to minimize that impact, the following points should be +taken into consideration until another solution is implemented: + +- Don't refresh the page for every test to clean the state of it. +- Instead, group the tests that are similar in different contexts. +- For every context login only once, clean the state between tests if needed without re-loading the page. +- All tests in a spec file must be order-independent. + - If you need to reload the page to make the tests order-independent, consider to create a new context. + +Remember that minimizing the number of times the web page is loaded, we minimize as well the execution time. + ## Authentication When running tests, there are two ways to specify the credentials used to diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts index 0e3717feef7ad0..6f7906d7fd7910 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts @@ -19,6 +19,8 @@ export const HEADER_SUBTITLE = `${EVENTS_VIEWER_PANEL} [data-test-subj="header-p /** The inspect query modal */ export const INSPECT_MODAL = '[data-test-subj="modal-inspect-euiModal"]'; +export const CLOSE_MODAL = '[data-test-subj="modal-inspect-close"]'; + /** The inspect query button that launches the inspect query modal */ export const INSPECT_QUERY = `${EVENTS_VIEWER_PANEL} [data-test-subj="inspect-icon-button"]`; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts index e3495b6a781271..405c8eb34d6fc7 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts @@ -42,3 +42,7 @@ export const clickOutsideFieldsBrowser = () => { export const filterFieldsBrowser = (fieldName: string) => { cy.get(FIELDS_BROWSER_FILTER_INPUT).type(fieldName); }; + +export const clearFieldsBrowser = () => { + cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{selectall}{backspace}'); +}; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts index 79169d3769a781..1450ee8dc8abf6 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts @@ -18,6 +18,7 @@ import { filterSearchBar, } from '../../lib/events_viewer/helpers'; import { + CLOSE_MODAL, EVENTS_VIEWER_PANEL, HEADER_SUBTITLE, INSPECT_MODAL, @@ -39,162 +40,162 @@ const defaultHeadersInDefaultEcsCategory = [ ]; describe('Events Viewer', () => { - beforeEach(() => { - loginAndWaitForPage(HOSTS_PAGE); - - clickEventsTab(); - }); - - it('renders the fields browser with the expected title when the Events Viewer Fields Browser button is clicked', () => { - openEventsViewerFieldsBrowser(); - - cy.get(FIELDS_BROWSER_TITLE) - .invoke('text') - .should('eq', 'Customize Columns'); - }); + context('Fields rendering', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); + }); - it('closes the fields browser when the user clicks outside of it', () => { - openEventsViewerFieldsBrowser(); + beforeEach(() => { + openEventsViewerFieldsBrowser(); + }); - clickOutsideFieldsBrowser(); + afterEach(() => { + clickOutsideFieldsBrowser(); + cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); + }); - cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); - }); + it('renders the fields browser with the expected title when the Events Viewer Fields Browser button is clicked', () => { + cy.get(FIELDS_BROWSER_TITLE) + .invoke('text') + .should('eq', 'Customize Columns'); + }); - it('displays the `default ECS` category (by default)', () => { - openEventsViewerFieldsBrowser(); + it('displays the `default ECS` category (by default)', () => { + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) + .invoke('text') + .should('eq', 'default ECS'); + }); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) - .invoke('text') - .should('eq', 'default ECS'); + it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => { + defaultHeadersInDefaultEcsCategory.forEach(header => + cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') + ); + }); }); - it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => { - openEventsViewerFieldsBrowser(); - - defaultHeadersInDefaultEcsCategory.forEach(header => - cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') - ); - }); + context('Events viewer query modal', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); + }); - it('removes the message field from the timeline when the user un-checks the field', () => { - const toggleField = 'message'; + after(() => { + cy.get(CLOSE_MODAL).click(); + cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('not.exist'); + }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist'); + it('launches the inspect query modal when the inspect button is clicked', () => { + // wait for data to load + cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) + .should('exist') + .invoke('text', { timeout: DEFAULT_TIMEOUT }) + .should('not.equal', '0'); - openEventsViewerFieldsBrowser(); + cy.get(INSPECT_QUERY, { timeout: DEFAULT_TIMEOUT }) + .should('exist') + .trigger('mousemove', { force: true }) + .click({ force: true }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).uncheck({ - force: true, + cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('exist'); }); - - clickOutsideFieldsBrowser(); - - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); }); - it('filters the events by applying filter criteria from the search bar at the top of the page', () => { - const filterInput = '4bf34c1c-eaa9-46de-8921-67a4ccc49829'; // this will never match real data - - cy.get(HEADER_SUBTITLE) - .invoke('text') - .then(text1 => { - cy.get(HEADER_SUBTITLE) - .invoke('text', { timeout: DEFAULT_TIMEOUT }) - .should('not.equal', 'Showing: 0 events'); + context('Events viewer fields behaviour', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); + }); - filterSearchBar(filterInput); + beforeEach(() => { + openEventsViewerFieldsBrowser(); + }); - cy.get(HEADER_SUBTITLE) - .invoke('text') - .should(text2 => { - expect(text1).not.to.eq(text2); - }); - }); - }); + it('adds a field to the events viewer when the user clicks the checkbox', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.city_name'; - it('adds a field to the events viewer when the user clicks the checkbox', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; + filterFieldsBrowser(filterInput); - openEventsViewerFieldsBrowser(); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - filterFieldsBrowser(filterInput); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ + force: true, + }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + clickOutsideFieldsBrowser(); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ - force: true, + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'exist' + ); }); - clickOutsideFieldsBrowser(); + it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.country_name'; - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist'); - }); + filterFieldsBrowser(filterInput); - it('loads more events when the load more button is clicked', () => { - cy.get(LOCAL_EVENTS_COUNT, { timeout: DEFAULT_TIMEOUT }) - .invoke('text') - .then(text1 => { - cy.get(LOCAL_EVENTS_COUNT) - .invoke('text') - .should('equal', '25'); - - cy.get(LOAD_MORE).click({ force: true }); - - cy.get(LOCAL_EVENTS_COUNT) - .invoke('text') - .should(text2 => { - expect(text1).not.to.eq(text2); - }); - }); - }); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - it('launches the inspect query modal when the inspect button is clicked', () => { - // wait for data to load - cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) - .should('exist') - .invoke('text', { timeout: DEFAULT_TIMEOUT }) - .should('not.equal', '0'); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ + force: true, + }); - cy.get(INSPECT_QUERY, { timeout: DEFAULT_TIMEOUT }) - .should('exist') - .trigger('mousemove', { force: true }) - .click({ force: true }); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="reset-fields"]`).click({ force: true }); - cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('exist'); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); + }); }); - it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; - - openEventsViewerFieldsBrowser(); - - filterFieldsBrowser(filterInput); - - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); - - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ - force: true, + context('Events behaviour', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); }); - clickOutsideFieldsBrowser(); + it('filters the events by applying filter criteria from the search bar at the top of the page', () => { + const filterInput = '4bf34c1c-eaa9-46de-8921-67a4ccc49829'; // this will never match real data - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist'); + cy.get(HEADER_SUBTITLE) + .invoke('text') + .then(text1 => { + cy.get(HEADER_SUBTITLE) + .invoke('text', { timeout: DEFAULT_TIMEOUT }) + .should('not.equal', 'Showing: 0 events'); - openEventsViewerFieldsBrowser(); + filterSearchBar(filterInput); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="reset-fields"]`).click({ force: true }); + cy.get(HEADER_SUBTITLE) + .invoke('text') + .should(text2 => { + expect(text1).not.to.eq(text2); + }); + }); + }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + it('loads more events when the load more button is clicked', () => { + cy.get(LOCAL_EVENTS_COUNT, { timeout: DEFAULT_TIMEOUT }) + .invoke('text') + .then(text1 => { + cy.get(LOCAL_EVENTS_COUNT) + .invoke('text') + .should('equal', '25'); + + cy.get(LOAD_MORE).click({ force: true }); + + cy.get(LOCAL_EVENTS_COUNT) + .invoke('text') + .should(text2 => { + expect(text1).not.to.eq(text2); + }); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts index 95df907893fc7b..d1289732b6d7db 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts @@ -6,6 +6,7 @@ import { drag, drop } from '../../lib/drag_n_drop/helpers'; import { + clearFieldsBrowser, clickOutsideFieldsBrowser, openTimelineFieldsBrowser, populateTimeline, @@ -37,233 +38,200 @@ const defaultHeaders = [ ]; describe('Fields Browser', () => { - beforeEach(() => { - loginAndWaitForPage(HOSTS_PAGE); - }); - - it('renders the fields browser with the expected title when the Fields button is clicked', () => { - populateTimeline(); - - openTimelineFieldsBrowser(); - - cy.get(FIELDS_BROWSER_TITLE) - .invoke('text') - .should('eq', 'Customize Columns'); - }); - - it('closes the fields browser when the user clicks outside of it', () => { - populateTimeline(); - - openTimelineFieldsBrowser(); - - clickOutsideFieldsBrowser(); - - cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); - }); - - it('displays the `default ECS` category (by default)', () => { - populateTimeline(); - - openTimelineFieldsBrowser(); - - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) - .invoke('text') - .should('eq', 'default ECS'); - }); - - it('the `defaultECS` (selected) category count matches the default timeline header count', () => { - populateTimeline(); - - openTimelineFieldsBrowser(); - - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT) - .invoke('text') - .should('eq', `${defaultHeaders.length}`); - }); - - it('displays a checked checkbox for all of the default timeline columns', () => { - populateTimeline(); - - openTimelineFieldsBrowser(); - - defaultHeaders.forEach(header => - cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') - ); - }); + context('Fields Browser rendering', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + populateTimeline(); + openTimelineFieldsBrowser(); + }); - it('removes the message field from the timeline when the user un-checks the field', () => { - const toggleField = 'message'; + afterEach(() => { + clearFieldsBrowser(); + }); - populateTimeline(); + it('renders the fields browser with the expected title when the Fields button is clicked', () => { + cy.get(FIELDS_BROWSER_TITLE) + .invoke('text') + .should('eq', 'Customize Columns'); + }); - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'exist' - ); + it('displays the `default ECS` category (by default)', () => { + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) + .invoke('text') + .should('eq', 'default ECS'); + }); - openTimelineFieldsBrowser(); + it('the `defaultECS` (selected) category count matches the default timeline header count', () => { + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT) + .invoke('text') + .should('eq', `${defaultHeaders.length}`); + }); - cy.get(`[data-test-subj="timeline"] [data-test-subj="field-${toggleField}-checkbox"]`).uncheck({ - force: true, + it('displays a checked checkbox for all of the default timeline columns', () => { + defaultHeaders.forEach(header => + cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') + ); }); - clickOutsideFieldsBrowser(); + it('displays the expected count of categories that match the filter input', () => { + const filterInput = 'host.mac'; - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); - }); + filterFieldsBrowser(filterInput); - it('displays the expected count of categories that match the filter input', () => { - const filterInput = 'host.mac'; + cy.get(FIELDS_BROWSER_CATEGORIES_COUNT) + .invoke('text') + .should('eq', '2 categories'); + }); - populateTimeline(); + it('displays a search results label with the expected count of fields matching the filter input', () => { + const filterInput = 'host.mac'; + + filterFieldsBrowser(filterInput); + + cy.get(FIELDS_BROWSER_FILTER_INPUT, { timeout: DEFAULT_TIMEOUT }).should( + 'not.have.class', + 'euiFieldSearch-isLoading' + ); + + cy.get(FIELDS_BROWSER_HOST_CATEGORIES_COUNT) + .invoke('text') + .then(hostCategoriesCount => { + cy.get(FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT) + .invoke('text') + .then(systemCategoriesCount => { + cy.get(FIELDS_BROWSER_FIELDS_COUNT) + .invoke('text') + .should('eq', `${+hostCategoriesCount + +systemCategoriesCount} fields`); + }); + }); + }); - openTimelineFieldsBrowser(); + it('displays a count of only the fields in the selected category that match the filter input', () => { + const filterInput = 'host.geo.c'; - filterFieldsBrowser(filterInput); + filterFieldsBrowser(filterInput); - cy.get(FIELDS_BROWSER_CATEGORIES_COUNT) - .invoke('text') - .should('eq', '2 categories'); + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT) + .invoke('text') + .should('eq', '4'); + }); }); - it('displays a search results label with the expected count of fields matching the filter input', () => { - const filterInput = 'host.mac'; - - populateTimeline(); + context('Editing the timeline', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + populateTimeline(); + openTimelineFieldsBrowser(); + }); - openTimelineFieldsBrowser(); + afterEach(() => { + openTimelineFieldsBrowser(); + clearFieldsBrowser(); + }); - filterFieldsBrowser(filterInput); + it('removes the message field from the timeline when the user un-checks the field', () => { + const toggleField = 'message'; - cy.get(FIELDS_BROWSER_FILTER_INPUT, { timeout: DEFAULT_TIMEOUT }).should( - 'not.have.class', - 'euiFieldSearch-isLoading' - ); + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'exist' + ); - cy.get(FIELDS_BROWSER_HOST_CATEGORIES_COUNT) - .invoke('text') - .then(hostCategoriesCount => { - cy.get(FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT) - .invoke('text') - .then(systemCategoriesCount => { - cy.get(FIELDS_BROWSER_FIELDS_COUNT) - .invoke('text') - .should('eq', `${+hostCategoriesCount + +systemCategoriesCount} fields`); - }); + cy.get( + `[data-test-subj="timeline"] [data-test-subj="field-${toggleField}-checkbox"]` + ).uncheck({ + force: true, }); - }); - - it('selects a search results label with the expected count of categories matching the filter input', () => { - const category = 'host'; - populateTimeline(); + clickOutsideFieldsBrowser(); - openTimelineFieldsBrowser(); + cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); - filterFieldsBrowser(`${category}.`); - - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) - .invoke('text') - .should('eq', category); - }); - - it('displays a count of only the fields in the selected category that match the filter input', () => { - const filterInput = 'host.geo.c'; - - populateTimeline(); + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); + }); - openTimelineFieldsBrowser(); + it('selects a search results label with the expected count of categories matching the filter input', () => { + const category = 'host'; - filterFieldsBrowser(filterInput); + filterFieldsBrowser(`${category}.`); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT) - .invoke('text') - .should('eq', '4'); - }); + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) + .invoke('text') + .should('eq', category); + }); - it('adds a field to the timeline when the user clicks the checkbox', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; + it('adds a field to the timeline when the user clicks the checkbox', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.city_name'; - populateTimeline(); + filterFieldsBrowser(filterInput); - openTimelineFieldsBrowser(); + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - filterFieldsBrowser(filterInput); + cy.get(`[data-test-subj="timeline"] [data-test-subj="field-${toggleField}-checkbox"]`).check({ + force: true, + }); - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + clickOutsideFieldsBrowser(); - cy.get(`[data-test-subj="timeline"] [data-test-subj="field-${toggleField}-checkbox"]`).check({ - force: true, + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`, { + timeout: DEFAULT_TIMEOUT, + }).should('exist'); }); - clickOutsideFieldsBrowser(); + it('adds a field to the timeline when the user drags and drops a field', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.country_name'; - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'exist' - ); - }); + filterFieldsBrowser(filterInput); - it('adds a field to the timeline when the user drags and drops a field', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - populateTimeline(); + cy.get( + `[data-test-subj="timeline"] [data-test-subj="field-name-${toggleField}"]` + ).then(field => drag(field)); - openTimelineFieldsBrowser(); + cy.get(`[data-test-subj="timeline"] [data-test-subj="headers-group"]`).then(headersDropArea => + drop(headersDropArea) + ); - filterFieldsBrowser(filterInput); - - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`, { + timeout: DEFAULT_TIMEOUT, + }).should('exist'); + }); - cy.get(`[data-test-subj="timeline"] [data-test-subj="field-name-${toggleField}"]`).then(field => - drag(field) - ); + it('resets all fields in the timeline when `Reset Fields` is clicked', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.continent_name'; - cy.get(`[data-test-subj="timeline"] [data-test-subj="headers-group"]`).then(headersDropArea => - drop(headersDropArea) - ); + filterFieldsBrowser(filterInput); - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`, { - timeout: DEFAULT_TIMEOUT, - }).should('exist'); - }); + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - it('resets all fields in the timeline when `Reset Fields` is clicked', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; + cy.get(`[data-test-subj="timeline"] [data-test-subj="field-${toggleField}-checkbox"]`).check({ + force: true, + }); - populateTimeline(); + clickOutsideFieldsBrowser(); - openTimelineFieldsBrowser(); + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'exist' + ); - filterFieldsBrowser(filterInput); + openTimelineFieldsBrowser(); - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + cy.get('[data-test-subj="timeline"] [data-test-subj="reset-fields"]').click({ force: true }); - cy.get(`[data-test-subj="timeline"] [data-test-subj="field-${toggleField}-checkbox"]`).check({ - force: true, + cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); }); - - clickOutsideFieldsBrowser(); - - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'exist' - ); - - openTimelineFieldsBrowser(); - - cy.get('[data-test-subj="timeline"] [data-test-subj="reset-fields"]').click({ force: true }); - - cy.get(`[data-test-subj="timeline"] [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts index bb1a0379ce0eaf..a549b5eec2e7c0 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { OVERVIEW_PAGE, TIMELINES_PAGE } from '../../lib/urls'; +import { TIMELINES_PAGE } from '../../lib/urls'; import { NAVIGATION_HOSTS, NAVIGATION_NETWORK, @@ -14,33 +14,27 @@ import { import { loginAndWaitForPage } from '../../lib/util/helpers'; describe('top-level navigation common to all pages in the SIEM app', () => { - it('navigates to the Overview page', () => { + before(() => { loginAndWaitForPage(TIMELINES_PAGE); - + }); + it('navigates to the Overview page', () => { cy.get(NAVIGATION_OVERVIEW).click({ force: true }); - cy.url().should('include', '/siem#/overview'); }); it('navigates to the Hosts page', () => { - loginAndWaitForPage(TIMELINES_PAGE); - cy.get(NAVIGATION_HOSTS).click({ force: true }); cy.url().should('include', '/siem#/hosts'); }); it('navigates to the Network page', () => { - loginAndWaitForPage(TIMELINES_PAGE); - cy.get(NAVIGATION_NETWORK).click({ force: true }); cy.url().should('include', '/siem#/network'); }); it('navigates to the Timelines page', () => { - loginAndWaitForPage(OVERVIEW_PAGE); - cy.get(NAVIGATION_TIMELINES).click({ force: true }); cy.url().should('include', '/siem#/timelines'); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts index 73711f1434d5ff..3853e703a7c071 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts @@ -18,9 +18,16 @@ import { import { DEFAULT_TIMEOUT, loginAndWaitForPage, waitForTableLoad } from '../../lib/util/helpers'; describe('Pagination', () => { - it('pagination updates results and page number', () => { + before(() => { loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); waitForTableLoad(UNCOMMON_PROCCESSES_TABLE); + }); + + afterEach(() => { + cy.get(getPageButtonSelector(0)).click({ force: true }); + }); + + it('pagination updates results and page number', () => { cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); cy.get(getDraggableField('process.name')) @@ -42,8 +49,6 @@ describe('Pagination', () => { }); it('pagination keeps track of page results when tabs change', () => { - loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); - waitForTableLoad(UNCOMMON_PROCCESSES_TABLE); cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); let thirdPageResult: string; cy.get(getPageButtonSelector(2)).click({ force: true }); @@ -78,7 +83,6 @@ describe('Pagination', () => { * when we figure out a way to really mock the data, we should come back to it */ it('pagination resets results and page number to first page when refresh is clicked', () => { - loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); cy.get(NUMBERED_PAGINATION, { timeout: DEFAULT_TIMEOUT }); cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); // let firstResult: string; diff --git a/x-pack/legacy/plugins/siem/cypress/support/index.js b/x-pack/legacy/plugins/siem/cypress/support/index.js index b20865149d02f3..9b86c2ffa94c69 100644 --- a/x-pack/legacy/plugins/siem/cypress/support/index.js +++ b/x-pack/legacy/plugins/siem/cypress/support/index.js @@ -22,5 +22,9 @@ // Import commands.js using ES2015 syntax: import './commands'; +Cypress.Cookies.defaults({ + whitelist: 'sid', +}); + // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx index 04d6d94d6624d6..a2a0ffdde34a50 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx @@ -20,7 +20,7 @@ import * as i18n from './translations'; const InspectContainer = styled.div<{ showInspect: boolean }>` .euiButtonIcon { - ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0')} + ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')} transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease; } `; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts index 91480f20d8b004..41e13408c1e01d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/helpers.ts @@ -181,6 +181,7 @@ export interface QueryTimelineById { apolloClient: ApolloClient | ApolloClient<{}> | undefined; duplicate: boolean; timelineId: string; + onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean; updateIsLoading: ActionCreator<{ id: string; isLoading: boolean }>; updateTimeline: DispatchUpdateTimeline; @@ -190,6 +191,7 @@ export const queryTimelineById = ({ apolloClient, duplicate = false, timelineId, + onOpenTimeline, openTimeline = true, updateIsLoading, updateTimeline, @@ -209,7 +211,9 @@ export const queryTimelineById = ({ ); const { timeline, notes } = formatTimelineResultToModel(timelineToOpen, duplicate); - if (updateTimeline) { + if (onOpenTimeline != null) { + onOpenTimeline(timeline); + } else if (updateTimeline) { updateTimeline({ duplicate, from: getOr(getDefaultFromValue(), 'dateRange.start', timeline), diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index c22c5fdbcfbc59..a97cfefaf0393f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -12,18 +12,20 @@ import { Dispatch } from 'redux'; import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers'; import { deleteTimelineMutation } from '../../containers/timeline/delete/persist.gql_query'; import { AllTimelinesVariables, AllTimelinesQuery } from '../../containers/timeline/all'; - import { allTimelinesQuery } from '../../containers/timeline/all/index.gql_query'; import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../graphql/types'; import { State, timelineSelectors } from '../../store'; +import { timelineDefaults, TimelineModel } from '../../store/timeline/model'; import { createTimeline as dispatchCreateNewTimeline, updateIsLoading as dispatchUpdateIsLoading, } from '../../store/timeline/actions'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { OpenTimeline } from './open_timeline'; import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers'; import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body'; import { + ActionTimelineToShow, DeleteTimelines, EuiSearchBarQuery, OnDeleteSelected, @@ -41,14 +43,14 @@ import { OpenTimelineReduxProps, } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; -import { ColumnHeader } from '../timeline/body/column_headers/column_header'; -import { timelineDefaults } from '../../store/timeline/model'; interface OwnProps { apolloClient: ApolloClient; /** Displays open timeline in modal */ isModal: boolean; closeModalTimeline?: () => void; + hideActions?: ActionTimelineToShow[]; + onOpenTimeline?: (timeline: TimelineModel) => void; } export type OpenTimelineOwnProps = OwnProps & @@ -69,15 +71,17 @@ export const getSelectedTimelineIds = (selectedItems: OpenTimelineResult[]): str /** Manages the state (e.g table selection) of the (pure) `OpenTimeline` component */ export const StatefulOpenTimelineComponent = React.memo( ({ + apolloClient, + closeModalTimeline, + createNewTimeline, defaultPageSize, + hideActions = [], isModal = false, + onOpenTimeline, + timeline, title, - apolloClient, - closeModalTimeline, updateTimeline, updateIsLoading, - timeline, - createNewTimeline, }) => { /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ const [itemIdToExpandedNotesRowMap, setItemIdToExpandedNotesRowMap] = useState< @@ -212,6 +216,7 @@ export const StatefulOpenTimelineComponent = React.memo( queryTimelineById({ apolloClient, duplicate, + onOpenTimeline, timelineId, updateIsLoading, updateTimeline, @@ -286,6 +291,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline-modal'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + hideActions={hideActions} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} onAddTimelinesToFavorites={undefined} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx index 1ed08eee633aba..a1ca7812bba340 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx @@ -143,7 +143,7 @@ describe('OpenTimeline', () => { ).toBe(true); }); - test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => { + test('it shows the delete action columns when onDeleteSelected and deleteTimelines are specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(true); + expect(props.actionTimelineToShow).toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => { + test('it does NOT show the delete action columns when is onDeleteSelected undefined and deleteTimelines is specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => { + test('it does NOT show the delete action columns when is onDeleteSelected provided and deleteTimelines is undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => { + test('it does NOT show the delete action when both onDeleteSelected and deleteTimelines are undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 59ccfc8b250aa5..8aab02b495392f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -57,6 +57,11 @@ export const OpenTimeline = React.memo( /> ( pageIndex={pageIndex} pageSize={pageSize} searchResults={searchResults} - showExtendedColumnsAndActions={onDeleteSelected != null && deleteTimelines != null} + showExtendedColumns={true} sortDirection={sortDirection} sortField={sortField} totalSearchResultsCount={totalSearchResultsCount} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx index cd89eb8aad6f43..c530929a3c96ee 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/index.tsx @@ -7,39 +7,49 @@ import { EuiModal, EuiOverlayMask } from '@elastic/eui'; import React from 'react'; +import { TimelineModel } from '../../../store/timeline/model'; import { useApolloClient } from '../../../utils/apollo_context'; + import * as i18n from '../translations'; +import { ActionTimelineToShow } from '../types'; import { StatefulOpenTimeline } from '..'; export interface OpenTimelineModalProps { onClose: () => void; + hideActions?: ActionTimelineToShow[]; + modalTitle?: string; + onOpen?: (timeline: TimelineModel) => void; } const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const OPEN_TIMELINE_MODAL_WIDTH = 1000; // px -export const OpenTimelineModal = React.memo(({ onClose }) => { - const apolloClient = useApolloClient(); - - if (!apolloClient) return null; - - return ( - - - - - - ); -}); +export const OpenTimelineModal = React.memo( + ({ hideActions = [], modalTitle, onClose, onOpen }) => { + const apolloClient = useApolloClient(); + + if (!apolloClient) return null; + + return ( + + + + + + ); + } +); OpenTimelineModal.displayName = 'OpenTimelineModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 1010504c0acace..2c3adb138b7acf 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -143,7 +143,7 @@ describe('OpenTimelineModal', () => { ).toBe(true); }); - test('it shows extended columns and actions when onDeleteSelected and deleteTimelines are specified', () => { + test('it shows the delete action when onDeleteSelected and deleteTimelines are specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(true); + expect(props.actionTimelineToShow).toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected undefined and deleteTimelines is specified', () => { + test('it does NOT show the delete when is onDeleteSelected undefined and deleteTimelines is specified', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when is onDeleteSelected provided and deleteTimelines is undefined', () => { + test('it does NOT show the delete action when is onDeleteSelected provided and deleteTimelines is undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); - test('it does NOT show extended columns and actions when both onDeleteSelected and deleteTimelines are undefined', () => { + test('it does NOT show extended columns when both onDeleteSelected and deleteTimelines are undefined', () => { const wrapper = mountWithIntl( { .first() .props() as TimelinesTableProps; - expect(props.showExtendedColumnsAndActions).toBe(false); + expect(props.actionTimelineToShow).not.toContain('delete'); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index e28725973aff2f..dcd0b377705830 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -5,10 +5,10 @@ */ import { EuiModalBody, EuiModalHeader } from '@elastic/eui'; -import React from 'react'; +import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; -import { OpenTimelineProps } from '../types'; +import { OpenTimelineProps, ActionTimelineToShow } from '../types'; import { SearchRow } from '../search_row'; import { TimelinesTable } from '../timelines_table'; import { TitleRow } from '../title_row'; @@ -19,10 +19,11 @@ export const HeaderContainer = styled.div` HeaderContainer.displayName = 'HeaderContainer'; -export const OpenTimelineModalBody = React.memo( +export const OpenTimelineModalBody = memo( ({ deleteTimelines, defaultPageSize, + hideActions = [], isLoading, itemIdToExpandedNotesRowMap, onAddTimelinesToFavorites, @@ -43,51 +44,61 @@ export const OpenTimelineModalBody = React.memo( sortField, title, totalSearchResultsCount, - }) => ( - <> - - - + }) => { + const actionsToShow = useMemo(() => { + const actions: ActionTimelineToShow[] = + onDeleteSelected != null && deleteTimelines != null + ? ['delete', 'duplicate'] + : ['duplicate']; + return actions.filter(action => !hideActions.includes(action)); + }, [onDeleteSelected, deleteTimelines, hideActions]); + return ( + <> + + + + + + + - + - - - - - - - - ) + + + ); + } ); OpenTimelineModalBody.displayName = 'OpenTimelineModalBody'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx index 89d6b4befa787c..eec11f571328f5 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -27,10 +27,11 @@ describe('#getActionsColumns', () => { mockResults = cloneDeep(mockTimelineResults); }); - test('it renders the delete timeline (trash icon) when showDeleteAction is true (because showExtendedColumnsAndActions is true)', () => { + test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -53,10 +54,11 @@ describe('#getActionsColumns', () => { expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(true); }); - test('it does NOT render the delete timeline (trash icon) when showDeleteAction is false (because showExtendedColumnsAndActions is false)', () => { + test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -79,10 +81,65 @@ describe('#getActionsColumns', () => { expect(wrapper.find('[data-test-subj="delete-timeline"]').exists()).toBe(false); }); + test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => { + const wrapper = mountWithIntl( + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(true); + }); + + test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => { + const wrapper = mountWithIntl( + + + + ); + + expect(wrapper.find('[data-test-subj="open-duplicate"]').exists()).toBe(false); + }); + test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -111,6 +168,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -141,6 +199,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -174,6 +233,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index 51dba21ac225c2..2b8bd3339cca24 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -12,19 +12,24 @@ import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; import { DeleteTimelineModalButton } from '../delete_timeline_modal'; import * as i18n from '../translations'; -import { DeleteTimelines, OnOpenTimeline, OpenTimelineResult } from '../types'; +import { + ActionTimelineToShow, + DeleteTimelines, + OnOpenTimeline, + OpenTimelineResult, +} from '../types'; /** * Returns the action columns (e.g. delete, open duplicate timeline) */ export const getActionsColumns = ({ + actionTimelineToShow, onOpenTimeline, deleteTimelines, - showDeleteAction, }: { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; onOpenTimeline: OnOpenTimeline; - showDeleteAction: boolean; }) => { const openAsDuplicateColumn = { align: 'center', @@ -67,7 +72,10 @@ export const getActionsColumns = ({ width: ACTION_COLUMN_WIDTH, }; - return showDeleteAction && deleteTimelines != null - ? [openAsDuplicateColumn, deleteTimelineColumn] - : [openAsDuplicateColumn]; + return [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null); }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx index 559ee4a7bb4946..0f2cda9d79f0b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx @@ -37,6 +37,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -63,6 +64,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingNotes.length} @@ -89,6 +91,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullNotes.length} @@ -115,6 +118,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptylNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptylNotes.length} @@ -143,6 +147,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -169,6 +174,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullSavedObjectId.length} @@ -195,6 +201,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -231,6 +238,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -269,6 +277,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -311,6 +320,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={hasNotes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={hasNotes.length} @@ -346,6 +356,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -377,6 +388,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -411,6 +423,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -442,6 +455,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingTitle.length} @@ -475,6 +489,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withMissingSavedObjectIdAndTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withMissingSavedObjectIdAndTitle.length} @@ -508,6 +523,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withJustWhitespaceTitle} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withJustWhitespaceTitle.length} @@ -541,6 +557,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={withMissingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={withMissingSavedObjectId.length} @@ -571,6 +588,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -605,6 +623,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingSavedObjectId} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingSavedObjectId.length} @@ -637,6 +656,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -673,6 +693,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -704,6 +725,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -737,6 +759,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingDescription} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingDescription.length} @@ -771,6 +794,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={justWhitespaceDescription} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={justWhitespaceDescription.length} @@ -803,6 +827,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -834,6 +859,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -868,6 +894,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingUpdated} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingUpdated.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx index 0b51bd78283c5a..0d3a73a389050d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.tsx @@ -27,11 +27,9 @@ export const getCommonColumns = ({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, }: { onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; - showExtendedColumnsAndActions: boolean; itemIdToExpandedNotesRowMap: Record; }) => [ { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx index bc88603721e8a2..4cbe1e45c473b9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -35,6 +35,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -66,6 +67,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -99,6 +101,7 @@ describe('#getExtendedColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={missingUpdatedBy} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={missingUpdatedBy.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx index 26836787efab10..31377d176acac9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -30,6 +30,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -57,6 +58,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={with6Events} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={with6Events.length} @@ -82,6 +84,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -109,6 +112,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={with4Notes} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={with4Notes.length} @@ -134,6 +138,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -161,6 +166,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={undefinedFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={undefinedFavorite.length} @@ -187,6 +193,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={nullFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={nullFavorite.length} @@ -213,6 +220,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} @@ -249,6 +257,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} @@ -289,6 +298,7 @@ describe('#getActionsColumns', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={emptyFavorite} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={emptyFavorite.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx index 7d947cb28e9ece..26d9607a91fcdb 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx @@ -28,10 +28,11 @@ describe('TimelinesTable', () => { mockResults = cloneDeep(mockTimelineResults); }); - test('it renders the select all timelines header checkbox when showExtendedColumnsAndActions is true', () => { + test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -59,10 +60,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the select all timelines header checkbox when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -90,10 +92,11 @@ describe('TimelinesTable', () => { ).toBe(false); }); - test('it renders the Modified By column when showExtendedColumnsAndActions is true ', () => { + test('it renders the Modified By column when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -121,10 +124,11 @@ describe('TimelinesTable', () => { ).toContain(i18n.MODIFIED_BY); }); - test('it renders the notes column in the position of the Modified By column when showExtendedColumnsAndActions is false', () => { + test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -148,16 +152,17 @@ describe('TimelinesTable', () => { wrapper .find('thead tr th') .at(5) - .find('[data-test-subj="notes-count-header-icon"]') + .find('svg[data-test-subj="notes-count-header-icon"]') .first() .exists() ).toBe(true); }); - test('it renders the delete timeline (trash icon) when showExtendedColumnsAndActions is true', () => { + test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -185,10 +190,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the delete timeline (trash icon) when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -216,10 +222,11 @@ describe('TimelinesTable', () => { ).toBe(false); }); - test('it renders the rows per page selector when showExtendedColumnsAndActions is true', () => { + test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -247,10 +254,11 @@ describe('TimelinesTable', () => { ).toBe(true); }); - test('it does NOT render the rows per page selector when showExtendedColumnsAndActions is false', () => { + test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -284,6 +292,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={defaultPageSize} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -311,10 +320,11 @@ describe('TimelinesTable', () => { ).toEqual('Rows per page: 123'); }); - test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is true ', () => { + test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -342,10 +352,11 @@ describe('TimelinesTable', () => { ).toContain(i18n.LAST_MODIFIED); }); - test('it sorts the Last Modified column in descending order when showExtendedColumnsAndActions is false ', () => { + test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -376,6 +387,7 @@ describe('TimelinesTable', () => { test('it displays the expected message when no search results are found', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={[]} - showExtendedColumnsAndActions={false} + showExtendedColumns={false} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={0} @@ -408,6 +420,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -446,6 +459,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -479,6 +493,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} @@ -510,6 +525,7 @@ describe('TimelinesTable', () => { const wrapper = mountWithIntl( { pageIndex={0} pageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE} searchResults={mockResults} - showExtendedColumnsAndActions={true} + showExtendedColumns={true} sortDirection={DEFAULT_SORT_DIRECTION} sortField={DEFAULT_SORT_FIELD} totalSearchResultsCount={mockResults.length} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index ce88ade01d2ef3..f09a9f6af048b5 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -10,6 +10,7 @@ import styled from 'styled-components'; import * as i18n from '../translations'; import { + ActionTimelineToShow, DeleteTimelines, OnOpenTimeline, OnSelectionChange, @@ -36,8 +37,8 @@ const BasicTable = styled(EuiBasicTable)` `; BasicTable.displayName = 'BasicTable'; -const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) => - showExtendedColumnsAndActions ? [...getExtendedColumns()] : []; +const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => + showExtendedColumns ? [...getExtendedColumns()] : []; /** * Returns the column definitions (passed as the `columns` prop to @@ -46,34 +47,36 @@ const getExtendedColumnsIfEnabled = (showExtendedColumnsAndActions: boolean) => * `Timelines` page */ const getTimelinesTableColumns = ({ + actionTimelineToShow, deleteTimelines, itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, + showExtendedColumns, }: { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; itemIdToExpandedNotesRowMap: Record; onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; - showExtendedColumnsAndActions: boolean; + showExtendedColumns: boolean; }) => [ ...getCommonColumns({ itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, - showExtendedColumnsAndActions, }), - ...getExtendedColumnsIfEnabled(showExtendedColumnsAndActions), + ...getExtendedColumnsIfEnabled(showExtendedColumns), ...getIconHeaderColumns(), ...getActionsColumns({ deleteTimelines, onOpenTimeline, - showDeleteAction: showExtendedColumnsAndActions, + actionTimelineToShow, }), ]; export interface TimelinesTableProps { + actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; defaultPageSize: number; loading: boolean; @@ -85,7 +88,7 @@ export interface TimelinesTableProps { pageIndex: number; pageSize: number; searchResults: OpenTimelineResult[]; - showExtendedColumnsAndActions: boolean; + showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; totalSearchResultsCount: number; @@ -97,6 +100,7 @@ export interface TimelinesTableProps { */ export const TimelinesTable = React.memo( ({ + actionTimelineToShow, deleteTimelines, defaultPageSize, loading: isLoading, @@ -108,13 +112,13 @@ export const TimelinesTable = React.memo( pageIndex, pageSize, searchResults, - showExtendedColumnsAndActions, + showExtendedColumns, sortField, sortDirection, totalSearchResultsCount, }) => { const pagination = { - hidePerPageOptions: !showExtendedColumnsAndActions, + hidePerPageOptions: !showExtendedColumns, pageIndex, pageSize, pageSizeOptions: [ @@ -142,16 +146,17 @@ export const TimelinesTable = React.memo( return ( ( noItemsMessage={i18n.ZERO_TIMELINES_MATCH} onChange={onTableChange} pagination={pagination} - selection={showExtendedColumnsAndActions ? selection : undefined} + selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} /> ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 7bbefb9efa99e1..e5e85ccf0954a2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -95,6 +95,8 @@ export interface OnTableChangeParams { /** Invoked by the EUI table implementation when the user interacts with the table */ export type OnTableChange = (tableChange: OnTableChangeParams) => void; +export type ActionTimelineToShow = 'duplicate' | 'delete' | 'selectable'; + export interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ deleteTimelines?: DeleteTimelines; @@ -140,6 +142,8 @@ export interface OpenTimelineProps { title: string; /** The total (server-side) count of the search results */ totalSearchResultsCount: number; + /** Hide action on timeline if needed it */ + hideActions?: ActionTimelineToShow[]; } export interface UpdateTimeline { diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap deleted file mode 100644 index d2579f427debea..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`HistogramSignals it renders 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx deleted file mode 100644 index 5d2f3256ef5091..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; - -import { TestProviders } from '../../../../mock'; -import { HistogramSignals } from './index'; - -describe('HistogramSignals', () => { - test('it renders', () => { - const wrapper = shallow( - - - - ); - - expect(wrapper.find('HistogramSignals')).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx deleted file mode 100644 index 35fe8a2d905095..00000000000000 --- a/x-pack/legacy/plugins/siem/public/components/page/detection_engine/histogram_signals/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - Axis, - Chart, - HistogramBarSeries, - Settings, - niceTimeFormatByDay, - timeFormatter, -} from '@elastic/charts'; -import React from 'react'; -import { npStart } from 'ui/new_platform'; - -export const HistogramSignals = React.memo(() => { - const sampleChartData = [ - { x: 1571090784000, y: 2, a: 'a' }, - { x: 1571090784000, y: 2, b: 'b' }, - { x: 1571093484000, y: 7, a: 'a' }, - { x: 1571096184000, y: 3, a: 'a' }, - { x: 1571098884000, y: 2, a: 'a' }, - { x: 1571101584000, y: 7, a: 'a' }, - { x: 1571104284000, y: 3, a: 'a' }, - { x: 1571106984000, y: 2, a: 'a' }, - { x: 1571109684000, y: 7, a: 'a' }, - { x: 1571112384000, y: 3, a: 'a' }, - { x: 1571115084000, y: 2, a: 'a' }, - { x: 1571117784000, y: 7, a: 'a' }, - { x: 1571120484000, y: 3, a: 'a' }, - { x: 1571123184000, y: 2, a: 'a' }, - { x: 1571125884000, y: 7, a: 'a' }, - { x: 1571128584000, y: 3, a: 'a' }, - { x: 1571131284000, y: 2, a: 'a' }, - { x: 1571133984000, y: 7, a: 'a' }, - { x: 1571136684000, y: 3, a: 'a' }, - { x: 1571139384000, y: 2, a: 'a' }, - { x: 1571142084000, y: 7, a: 'a' }, - { x: 1571144784000, y: 3, a: 'a' }, - { x: 1571147484000, y: 2, a: 'a' }, - { x: 1571150184000, y: 7, a: 'a' }, - { x: 1571152884000, y: 3, a: 'a' }, - { x: 1571155584000, y: 2, a: 'a' }, - { x: 1571158284000, y: 7, a: 'a' }, - { x: 1571160984000, y: 3, a: 'a' }, - { x: 1571163684000, y: 2, a: 'a' }, - { x: 1571166384000, y: 7, a: 'a' }, - { x: 1571169084000, y: 3, a: 'a' }, - { x: 1571171784000, y: 2, a: 'a' }, - { x: 1571174484000, y: 7, a: 'a' }, - ]; - - return ( - - - - - - - - - - ); -}); -HistogramSignals.displayName = 'HistogramSignals'; diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 098f54640e4b22..5ed750b519cbfb 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -105,7 +105,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = showInspect={false} >
void; +} + +const basicSuperSelectOptions = [ + { + value: '-1', + inputDisplay: i18n.DEFAULT_TIMELINE_TITLE, + }, +]; + +const getBasicSelectableOptions = (timelineId: string) => [ + { + description: i18n.DEFAULT_TIMELINE_DESCRIPTION, + label: i18n.DEFAULT_TIMELINE_TITLE, + id: null, + title: i18n.DEFAULT_TIMELINE_TITLE, + checked: timelineId === '-1' ? 'on' : undefined, + } as Option, +]; + +const ORIGINAL_PAGE_SIZE = 50; +const POPOVER_HEIGHT = 260; +const TIMELINE_ITEM_HEIGHT = 50; +const SearchTimelineSuperSelectComponent: React.FC = ({ + isDisabled, + timelineId, + timelineTitle, + onTimelineChange, +}) => { + const [pageSize, setPageSize] = useState(ORIGINAL_PAGE_SIZE); + const [heightTrigger, setHeightTrigger] = useState(0); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchTimelineValue, setSearchTimelineValue] = useState(''); + const [onlyFavorites, setOnlyFavorites] = useState(false); + const [searchRef, setSearchRef] = useState(null); + + const onSearchTimeline = useCallback(val => { + setSearchTimelineValue(val); + }, []); + + const handleClosePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const handleOpenPopover = useCallback(() => { + setIsPopoverOpen(true); + }, []); + + const handleOnToggleOnlyFavorites = useCallback(() => { + setOnlyFavorites(!onlyFavorites); + }, [onlyFavorites]); + + const renderTimelineOption = useCallback((option, searchValue) => { + return ( + + + + + + + + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + + + + + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + + + + + + + ); + }, []); + + const handleTimelineChange = useCallback( + options => { + const selectedTimeline = options.filter( + (option: { checked: string }) => option.checked === 'on' + ); + if (selectedTimeline != null && selectedTimeline.length > 0) { + onTimelineChange( + isEmpty(selectedTimeline[0].title) + ? i18nTimeline.UNTITLED_TIMELINE + : selectedTimeline[0].title, + selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id + ); + } + setIsPopoverOpen(false); + }, + [onTimelineChange] + ); + + const handleOnScroll = useCallback( + ( + totalTimelines: number, + totalCount: number, + { + clientHeight, + scrollHeight, + scrollTop, + }: { + clientHeight: number; + scrollHeight: number; + scrollTop: number; + } + ) => { + if (totalTimelines < totalCount) { + const clientHeightTrigger = clientHeight * 1.2; + if ( + scrollTop > 10 && + scrollHeight - scrollTop < clientHeightTrigger && + scrollHeight > heightTrigger + ) { + setHeightTrigger(scrollHeight); + setPageSize(pageSize + ORIGINAL_PAGE_SIZE); + } + } + }, + [heightTrigger, pageSize] + ); + + const superSelect = useMemo( + () => ( + + ), + [handleOpenPopover, isDisabled, timelineId, timelineTitle] + ); + + const favoritePortal = useMemo( + () => + searchRef != null ? ( + + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + ) : null, + [searchRef, onlyFavorites, handleOnToggleOnlyFavorites] + ); + + return ( + + + {({ timelines, loading, totalCount }) => ( + + { + setSearchRef(ref); + }, + }} + singleSelection={true} + options={[ + ...(!onlyFavorites && searchTimelineValue === '' + ? getBasicSelectableOptions(timelineId == null ? '-1' : timelineId) + : []), + ...timelines.map( + (t, index) => + ({ + description: t.description, + favorite: !isEmpty(t.favorite), + label: t.title, + id: t.savedObjectId, + key: `${t.title}-${index}`, + title: t.title, + checked: t.savedObjectId === timelineId ? 'on' : undefined, + } as Option) + ), + ]} + > + {(list, search) => ( + <> + {search} + {favoritePortal} + {list} + + )} + + + )} + + + + ); +}; + +export const SearchTimelineSuperSelect = memo(SearchTimelineSuperSelectComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts new file mode 100644 index 00000000000000..bffee407bc9995 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/timeline/search_super_select/translations.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const DEFAULT_TIMELINE_TITLE = i18n.translate('xpack.siem.timeline.defaultTimelineTitle', { + defaultMessage: 'Default blank timeline', +}); + +export const DEFAULT_TIMELINE_DESCRIPTION = i18n.translate( + 'xpack.siem.timeline.defaultTimelineDescription', + { + defaultMessage: 'Timeline offered by default when creating new timeline.', + } +); + +export const SEARCH_BOX_TIMELINE_PLACEHOLDER = i18n.translate( + 'xpack.siem.timeline.searchBoxPlaceholder', + { + defaultMessage: 'e.g. timeline name or description', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index f9611995cdb047..b69a8de29e0474 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -15,9 +15,13 @@ import { NewRule, Rule, FetchRuleProps, + BasicFetchProps, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../common/constants'; +import { + DETECTION_ENGINE_RULES_URL, + DETECTION_ENGINE_PREPACKAGED_URL, +} from '../../../../common/constants'; /** * Add provided Rule @@ -199,3 +203,22 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise>(response => response.json()) ); }; + +/** + * Create Prepackaged Rules + * + * @param signal AbortSignal for cancelling request + */ +export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => { + const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_PREPACKAGED_URL}`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal, + }); + await throwIfNotOk(response); + return true; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 9f3cba7189fb19..a329d96d444aac 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -70,8 +70,8 @@ export const RuleSchema = t.intersection([ risk_score: t.number, rule_id: t.string, severity: t.string, - type: t.string, tags: t.array(t.string), + type: t.string, to: t.string, threats: t.array(t.unknown), updated_at: t.string, @@ -79,6 +79,8 @@ export const RuleSchema = t.intersection([ }), t.partial({ saved_id: t.string, + timeline_id: t.string, + timeline_title: t.string, }), ]); @@ -130,3 +132,7 @@ export interface DeleteRulesProps { export interface DuplicateRulesProps { rules: Rules; } + +export interface BasicFetchProps { + signal: AbortSignal; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts index e7641fd37678e2..8754d73637e7c7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/api.ts @@ -42,7 +42,7 @@ export const fetchQuerySignals = async ({ 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - body: query, + body: JSON.stringify(query), signal, }); await throwIfNotOk(response); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts index 5b5dc9e9699fe9..2b8f54e5438dfb 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts @@ -12,3 +12,24 @@ export const SIGNAL_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to query signals', } ); + +export const PRIVILEGE_FETCH_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorFetchingSignalsDescription', + { + defaultMessage: 'Failed to query signals', + } +); + +export const SIGNAL_GET_NAME_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorGetSignalDescription', + { + defaultMessage: 'Failed to get signal index name', + } +); + +export const SIGNAL_POST_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorPostSignalDescription', + { + defaultMessage: 'Failed to create signal index', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts index 32f53691bae874..34cb7684a03993 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/types.ts @@ -10,7 +10,7 @@ export interface BasicSignals { signal: AbortSignal; } export interface QuerySignals extends BasicSignals { - query: string; + query: object; } export interface SignalsResponse { @@ -18,7 +18,8 @@ export interface SignalsResponse { timeout: boolean; } -export interface SignalSearchResponse extends SignalsResponse { +export interface SignalSearchResponse + extends SignalsResponse { _shards: { total: number; successful: number; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx index aa66df53d9fd9f..792ff29ad24885 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx @@ -6,10 +6,18 @@ import { useEffect, useState } from 'react'; +import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; +import { useStateToaster } from '../../../components/toasters'; import { getUserPrivilege } from './api'; +import * as i18n from './translations'; -type Return = [boolean, boolean | null, boolean | null]; - +interface Return { + loading: boolean; + isAuthenticated: boolean | null; + hasIndexManage: boolean | null; + hasManageApiKey: boolean | null; + hasIndexWrite: boolean | null; +} /** * Hook to get user privilege from * @@ -17,7 +25,10 @@ type Return = [boolean, boolean | null, boolean | null]; export const usePrivilegeUser = (): Return => { const [loading, setLoading] = useState(true); const [isAuthenticated, setAuthenticated] = useState(null); - const [hasWrite, setHasWrite] = useState(null); + const [hasIndexManage, setHasIndexManage] = useState(null); + const [hasIndexWrite, setHasIndexWrite] = useState(null); + const [hasManageApiKey, setHasManageApiKey] = useState(null); + const [, dispatchToaster] = useStateToaster(); useEffect(() => { let isSubscribed = true; @@ -34,13 +45,21 @@ export const usePrivilegeUser = (): Return => { setAuthenticated(privilege.isAuthenticated); if (privilege.index != null && Object.keys(privilege.index).length > 0) { const indexName = Object.keys(privilege.index)[0]; - setHasWrite(privilege.index[indexName].create_index); + setHasIndexManage(privilege.index[indexName].manage); + setHasIndexWrite(privilege.index[indexName].write); + setHasManageApiKey( + privilege.cluster.manage_security || + privilege.cluster.manage_api_key || + privilege.cluster.manage_own_api_key + ); } } } catch (error) { if (isSubscribed) { setAuthenticated(false); - setHasWrite(false); + setHasIndexManage(false); + setHasIndexWrite(false); + errorToToaster({ title: i18n.PRIVILEGE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -55,5 +74,5 @@ export const usePrivilegeUser = (): Return => { }; }, []); - return [loading, isAuthenticated, hasWrite]; + return { loading, isAuthenticated, hasIndexManage, hasManageApiKey, hasIndexWrite }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx index 65a5ac866e68d7..fa88a84fb11871 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_query.tsx @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useState } from 'react'; +import React, { SetStateAction, useEffect, useState } from 'react'; import { fetchQuerySignals } from './api'; import { SignalSearchResponse } from './types'; -type Return = [boolean, SignalSearchResponse | null]; +type Return = [ + boolean, + SignalSearchResponse | null, + React.Dispatch> +]; /** * Hook for using to get a Signals from the Detection Engine API * - * @param query convert a dsl into string + * @param initialQuery query dsl object * */ -export const useQuerySignals = (query: string): Return => { +export const useQuerySignals = (initialQuery: object): Return => { + const [query, setQuery] = useState(initialQuery); const [signals, setSignals] = useState | null>(null); const [loading, setLoading] = useState(true); @@ -53,5 +58,5 @@ export const useQuerySignals = (query: string): Return => }; }, [query]); - return [loading, signals]; + return [loading, signals, setQuery]; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx index 1ff4422cf6411d..189d8a1bf3f758 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx @@ -8,9 +8,10 @@ import { useEffect, useState, useRef } from 'react'; import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../../components/toasters'; +import { createPrepackagedRules } from '../rules'; import { createSignalIndex, getSignalIndex } from './api'; import * as i18n from './translations'; -import { PostSignalError } from './types'; +import { PostSignalError, SignalIndexError } from './types'; type Func = () => void; @@ -40,11 +41,15 @@ export const useSignalIndex = (): Return => { if (isSubscribed && signal != null) { setSignalIndexName(signal.name); setSignalIndexExists(true); + createPrepackagedRules({ signal: abortCtrl.signal }); } } catch (error) { if (isSubscribed) { setSignalIndexName(null); setSignalIndexExists(false); + if (error instanceof SignalIndexError && error.statusCode !== 404) { + errorToToaster({ title: i18n.SIGNAL_GET_NAME_FAILURE, error, dispatchToaster }); + } } } if (isSubscribed) { @@ -69,7 +74,7 @@ export const useSignalIndex = (): Return => { } else { setSignalIndexName(null); setSignalIndexExists(false); - errorToToaster({ title: i18n.SIGNAL_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.SIGNAL_POST_FAILURE, error, dispatchToaster }); } } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx new file mode 100644 index 00000000000000..19505319984506 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +const NoWriteSignalsCallOutComponent = () => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + +

{i18n.NO_WRITE_SIGNALS_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const NoWriteSignalsCallOut = memo(NoWriteSignalsCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts new file mode 100644 index 00000000000000..065d775e1dc6a7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const NO_WRITE_SIGNALS_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.noWriteSignalsCallOutTitle', + { + defaultMessage: 'Signals index permissions required', + } +); + +export const NO_WRITE_SIGNALS_CALLOUT_MSG = i18n.translate( + 'xpack.siem.detectionEngine.noWriteSignalsCallOutMsg', + { + defaultMessage: + 'You are currently missing the required permissions to update signals. Please contact your administrator for further assistance.', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.detectionEngine.dismissNoWriteSignalButton', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx index 1a7ad5822a2468..83b6ba690ec5bf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx @@ -168,55 +168,66 @@ export const requiredFieldsForActions = [ ]; export const getSignalsActions = ({ + canUserCRUD, + hasIndexWrite, setEventsLoading, setEventsDeleted, createTimeline, status, }: { + canUserCRUD: boolean; + hasIndexWrite: boolean; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; createTimeline: CreateTimeline; status: 'open' | 'closed'; -}): TimelineAction[] => [ - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - sendSignalsToTimelineAction({ createTimeline, data: [data] })} - iconType="tableDensityNormal" - aria-label="Next" - /> - - ), - id: 'sendSignalToTimeline', - width: 26, - }, - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - - updateSignalStatusAction({ - signalIds: [eventId], - status, - setEventsLoading, - setEventsDeleted, - }) - } - iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} - aria-label="Next" - /> - - ), - id: 'updateSignalStatus', - width: 26, - }, -]; +}): TimelineAction[] => { + const actions = [ + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + sendSignalsToTimelineAction({ createTimeline, data: [data] })} + iconType="tableDensityNormal" + aria-label="Next" + /> + + ), + id: 'sendSignalToTimeline', + width: 26, + }, + ]; + return canUserCRUD && hasIndexWrite + ? [ + ...actions, + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + + updateSignalStatusAction({ + signalIds: [eventId], + status, + setEventsLoading, + setEventsDeleted, + }) + } + iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} + aria-label="Next" + /> + + ), + id: 'updateSignalStatus', + width: 26, + }, + ] + : actions; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index 47a78482cfb6e9..d149eb700ad039 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; @@ -46,6 +47,8 @@ import { useFetchIndexPatterns } from '../../../../containers/detection_engine/r import { InputsRange } from '../../../../store/inputs/model'; import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { HeaderSection } from '../../../../components/header_section'; + const SIGNALS_PAGE_TIMELINE_ID = 'signals-page'; interface ReduxProps { @@ -88,8 +91,11 @@ interface DispatchProps { } interface OwnProps { + canUserCRUD: boolean; defaultFilters?: esFilters.Filter[]; + hasIndexWrite: boolean; from: number; + loading: boolean; signalsIndex: string; to: number; } @@ -98,6 +104,7 @@ type SignalsTableComponentProps = OwnProps & ReduxProps & DispatchProps; export const SignalsTableComponent = React.memo( ({ + canUserCRUD, createTimeline, clearEventsDeleted, clearEventsLoading, @@ -106,7 +113,9 @@ export const SignalsTableComponent = React.memo( from, globalFilters, globalQuery, + hasIndexWrite, isSelectAllChecked, + loading, loadingEventIds, removeTimelineLinkTo, selectedEventIds, @@ -228,8 +237,10 @@ export const SignalsTableComponent = React.memo( (totalCount: number) => { return ( 0} clearSelection={clearSelectionCallback} + hasIndexWrite={hasIndexWrite} isFilteredToOpen={filterGroup === FILTER_OPEN} selectAll={selectAllCallback} selectedEventIds={selectedEventIds} @@ -241,6 +252,8 @@ export const SignalsTableComponent = React.memo( ); }, [ + canUserCRUD, + hasIndexWrite, clearSelectionCallback, filterGroup, loadingEventIds.length, @@ -254,12 +267,14 @@ export const SignalsTableComponent = React.memo( const additionalActions = useMemo( () => getSignalsActions({ + canUserCRUD, + hasIndexWrite, createTimeline: createTimelineCallback, setEventsLoading: setEventsLoadingCallback, setEventsDeleted: setEventsDeletedCallback, status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN, }), - [createTimelineCallback, filterGroup] + [canUserCRUD, createTimelineCallback, filterGroup] ); const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]); @@ -279,11 +294,20 @@ export const SignalsTableComponent = React.memo( queryFields: requiredFieldsForActions, timelineActions: additionalActions, title: i18n.SIGNALS_TABLE_TITLE, - selectAll, + selectAll: canUserCRUD ? selectAll : false, }), - [additionalActions, selectAll] + [additionalActions, canUserCRUD, selectAll] ); + if (loading) { + return ( + + + + + ); + } + return ( >; + updateSignalsStatus: UpdateSignalsStatus; + sendSignalsToTimeline: SendSignalsToTimeline; + closePopover: () => void; + isFilteredToOpen: boolean; +} /** * Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel * @@ -22,15 +31,15 @@ import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group'; * @param closePopover * @param isFilteredToOpen currently selected filter options */ -export const getBatchItems = ( - areEventsLoading: boolean, - allEventsSelected: boolean, - selectedEventIds: Readonly>, - updateSignalsStatus: UpdateSignalsStatus, - sendSignalsToTimeline: SendSignalsToTimeline, - closePopover: () => void, - isFilteredToOpen: boolean -) => { +export const getBatchItems = ({ + areEventsLoading, + allEventsSelected, + selectedEventIds, + updateSignalsStatus, + sendSignalsToTimeline, + closePopover, + isFilteredToOpen, +}: GetBatchItems) => { const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0; const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1; const filterString = isFilteredToOpen diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx index f80de053b59bdf..e28fb3e06870ee 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx @@ -22,6 +22,8 @@ import { TimelineNonEcsData } from '../../../../../graphql/types'; import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types'; interface SignalsUtilityBarProps { + canUserCRUD: boolean; + hasIndexWrite: boolean; areEventsLoading: boolean; clearSelection: () => void; isFilteredToOpen: boolean; @@ -34,6 +36,8 @@ interface SignalsUtilityBarProps { } const SignalsUtilityBarComponent: React.FC = ({ + canUserCRUD, + hasIndexWrite, areEventsLoading, clearSelection, totalCount, @@ -49,15 +53,15 @@ const SignalsUtilityBarComponent: React.FC = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), [ @@ -66,6 +70,7 @@ const SignalsUtilityBarComponent: React.FC = ({ updateSignalsStatus, sendSignalsToTimeline, isFilteredToOpen, + hasIndexWrite, ] ); @@ -83,7 +88,7 @@ const SignalsUtilityBarComponent: React.FC = ({ - {totalCount > 0 && ( + {canUserCRUD && hasIndexWrite && ( <> {i18n.SELECTED_SIGNALS( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx deleted file mode 100644 index 01ebafdccfefdb..00000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_chart/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiPanel, EuiSelect } from '@elastic/eui'; -import { noop } from 'lodash/fp'; -import React, { memo } from 'react'; - -import { HeaderSection } from '../../../../components/header_section'; -import { HistogramSignals } from '../../../../components/page/detection_engine/histogram_signals'; - -export const sampleChartOptions = [ - { text: 'Risk scores', value: 'risk_scores' }, - { text: 'Severities', value: 'severities' }, - { text: 'Top destination IPs', value: 'destination_ips' }, - { text: 'Top event actions', value: 'event_actions' }, - { text: 'Top event categories', value: 'event_categories' }, - { text: 'Top host names', value: 'host_names' }, - { text: 'Top rule types', value: 'rule_types' }, - { text: 'Top rules', value: 'rules' }, - { text: 'Top source IPs', value: 'source_ips' }, - { text: 'Top users', value: 'users' }, -]; - -const SignalsChartsComponent = () => ( - - - noop} - prepend="Stack by" - value={sampleChartOptions[0].value} - /> - - - - -); - -export const SignalsCharts = memo(SignalsChartsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts new file mode 100644 index 00000000000000..f329780b075e3f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/config.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as i18n from './translations'; +import { SignalsHistogramOption } from './types'; + +export const signalsHistogramOptions: SignalsHistogramOption[] = [ + { text: i18n.STACK_BY_RISK_SCORES, value: 'signal.rule.risk_score' }, + { text: i18n.STACK_BY_SEVERITIES, value: 'signal.rule.severity' }, + { text: i18n.STACK_BY_DESTINATION_IPS, value: 'destination.ip' }, + { text: i18n.STACK_BY_ACTIONS, value: 'event.action' }, + { text: i18n.STACK_BY_CATEGORIES, value: 'event.category' }, + { text: i18n.STACK_BY_HOST_NAMES, value: 'host.name' }, + { text: i18n.STACK_BY_RULE_TYPES, value: 'signal.rule.type' }, + { text: i18n.STACK_BY_RULE_NAMES, value: 'signal.rule.name' }, + { text: i18n.STACK_BY_SOURCE_IPS, value: 'source.ip' }, + { text: i18n.STACK_BY_USERS, value: 'user.name' }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx new file mode 100644 index 00000000000000..fda40f5f9fa5db --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Position } from '@elastic/charts'; +import { EuiButton, EuiPanel, EuiSelect } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import React, { memo, useCallback, useMemo, useState } from 'react'; + +import { HeaderSection } from '../../../../components/header_section'; +import { SignalsHistogram } from './signals_histogram'; + +import * as i18n from './translations'; +import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { SignalsHistogramOption, SignalsTotal } from './types'; +import { signalsHistogramOptions } from './config'; +import { getDetectionEngineUrl } from '../../../../components/link_to'; +import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; +import { useUiSetting$ } from '../../../../lib/kibana'; + +const defaultTotalSignalsObj: SignalsTotal = { + value: 0, + relation: 'eq', +}; + +interface SignalsHistogramPanelProps { + defaultStackByOption?: SignalsHistogramOption; + filters?: esFilters.Filter[]; + from: number; + query?: Query; + legendPosition?: Position; + loadingInitial?: boolean; + showLinkToSignals?: boolean; + showTotalSignalsCount?: boolean; + stackByOptions?: SignalsHistogramOption[]; + title?: string; + to: number; + updateDateRange: (min: number, max: number) => void; +} + +export const SignalsHistogramPanel = memo( + ({ + defaultStackByOption = signalsHistogramOptions[0], + filters, + query, + from, + legendPosition = 'bottom', + loadingInitial = false, + showLinkToSignals = false, + showTotalSignalsCount = false, + stackByOptions, + to, + title = i18n.HISTOGRAM_HEADER, + updateDateRange, + }) => { + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const [totalSignalsObj, setTotalSignalsObj] = useState(defaultTotalSignalsObj); + const [selectedStackByOption, setSelectedStackByOption] = useState( + defaultStackByOption + ); + + const totalSignals = useMemo( + () => + i18n.SHOWING_SIGNALS( + numeral(totalSignalsObj.value).format(defaultNumberFormat), + totalSignalsObj.value, + totalSignalsObj.relation === 'gte' ? '>' : totalSignalsObj.relation === 'lte' ? '<' : '' + ), + [totalSignalsObj] + ); + + const setSelectedOptionCallback = useCallback((event: React.ChangeEvent) => { + setSelectedStackByOption( + stackByOptions?.find(co => co.value === event.target.value) ?? defaultStackByOption + ); + }, []); + + return ( + + + {stackByOptions && ( + + )} + {showLinkToSignals && ( + {i18n.VIEW_SIGNALS} + )} + + + + + ); + } +); + +SignalsHistogramPanel.displayName = 'SignalsHistogramPanel'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx new file mode 100644 index 00000000000000..ed503e9872f0af --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/helpers.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HistogramData, SignalsAggregation, SignalsBucket, SignalsGroupBucket } from '../types'; +import { SignalSearchResponse } from '../../../../../containers/detection_engine/signals/types'; +import * as i18n from '../translations'; + +export const formatSignalsData = ( + signalsData: SignalSearchResponse<{}, SignalsAggregation> | null +) => { + const groupBuckets: SignalsGroupBucket[] = + signalsData?.aggregations?.signalsByGrouping?.buckets ?? []; + return groupBuckets.reduce((acc, { key: group, signals }) => { + const signalsBucket: SignalsBucket[] = signals.buckets ?? []; + + return [ + ...acc, + ...signalsBucket.map(({ key, doc_count }: SignalsBucket) => ({ + x: key, + y: doc_count, + g: group, + })), + ]; + }, []); +}; + +export const getSignalsHistogramQuery = ( + stackByField: string, + from: number, + to: number, + additionalFilters: Array<{ + bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; + }> +) => ({ + aggs: { + signalsByGrouping: { + terms: { + field: stackByField, + missing: stackByField.endsWith('.ip') ? '0.0.0.0' : i18n.ALL_OTHERS, + order: { + _count: 'desc', + }, + size: 10, + }, + aggs: { + signals: { + auto_date_histogram: { + field: '@timestamp', + buckets: 36, + }, + }, + }, + }, + }, + query: { + bool: { + filter: [ + ...additionalFilters, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx new file mode 100644 index 00000000000000..218fcc3a70f79f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/signals_histogram/index.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Axis, + Chart, + getAxisId, + getSpecId, + HistogramBarSeries, + niceTimeFormatByDay, + Position, + Settings, + timeFormatter, +} from '@elastic/charts'; +import React, { useEffect, useMemo } from 'react'; +import { EuiLoadingContent } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import { useQuerySignals } from '../../../../../containers/detection_engine/signals/use_query'; +import { Query } from '../../../../../../../../../../src/plugins/data/common/query'; +import { esFilters, esQuery } from '../../../../../../../../../../src/plugins/data/common/es_query'; +import { SignalsAggregation, SignalsTotal } from '../types'; +import { formatSignalsData, getSignalsHistogramQuery } from './helpers'; +import { useTheme } from '../../../../../components/charts/common'; +import { useKibana } from '../../../../../lib/kibana'; + +interface HistogramSignalsProps { + filters?: esFilters.Filter[]; + from: number; + legendPosition?: Position; + loadingInitial: boolean; + query?: Query; + setTotalSignalsCount: React.Dispatch; + stackByField: string; + to: number; + updateDateRange: (min: number, max: number) => void; +} + +export const SignalsHistogram = React.memo( + ({ + to, + from, + query, + filters, + legendPosition = 'bottom', + loadingInitial, + setTotalSignalsCount, + stackByField, + updateDateRange, + }) => { + const [isLoadingSignals, signalsData, setQuery] = useQuerySignals<{}, SignalsAggregation>( + getSignalsHistogramQuery(stackByField, from, to, []) + ); + const theme = useTheme(); + const kibana = useKibana(); + + const formattedSignalsData = useMemo(() => formatSignalsData(signalsData), [signalsData]); + + useEffect(() => { + setTotalSignalsCount( + signalsData?.hits.total ?? { + value: 0, + relation: 'eq', + } + ); + }, [signalsData]); + + useEffect(() => { + const converted = esQuery.buildEsQuery( + undefined, + query != null ? [query] : [], + filters?.filter(f => f.meta.disabled === false) ?? [], + { + ...esQuery.getEsQueryConfig(kibana.services.uiSettings), + dateFormatTZ: undefined, + } + ); + + setQuery( + getSignalsHistogramQuery(stackByField, from, to, !isEmpty(converted) ? [converted] : []) + ); + }, [stackByField, from, to, query, filters]); + + return ( + <> + {loadingInitial || isLoadingSignals ? ( + + ) : ( + + + + + + + + + + )} + + ); + } +); +SignalsHistogram.displayName = 'SignalsHistogram'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts new file mode 100644 index 00000000000000..0245b9968cc360 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/translations.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const STACK_BY_LABEL = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.stackByLabel', + { + defaultMessage: 'Stack by', + } +); + +export const STACK_BY_RISK_SCORES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.riskScoresDropDown', + { + defaultMessage: 'Risk scores', + } +); + +export const STACK_BY_SEVERITIES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.severitiesDropDown', + { + defaultMessage: 'Severities', + } +); + +export const STACK_BY_DESTINATION_IPS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.destinationIpsDropDown', + { + defaultMessage: 'Top destination IPs', + } +); + +export const STACK_BY_SOURCE_IPS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.sourceIpsDropDown', + { + defaultMessage: 'Top source IPs', + } +); + +export const STACK_BY_ACTIONS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventActionsDropDown', + { + defaultMessage: 'Top event actions', + } +); + +export const STACK_BY_CATEGORIES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.eventCategoriesDropDown', + { + defaultMessage: 'Top event categories', + } +); + +export const STACK_BY_HOST_NAMES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.hostNamesDropDown', + { + defaultMessage: 'Top host names', + } +); + +export const STACK_BY_RULE_TYPES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.ruleTypesDropDown', + { + defaultMessage: 'Top rule types', + } +); + +export const STACK_BY_RULE_NAMES = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.rulesDropDown', + { + defaultMessage: 'Top rules', + } +); + +export const STACK_BY_USERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.stackByOptions.usersDropDown', + { + defaultMessage: 'Top users', + } +); + +export const HISTOGRAM_HEADER = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.headerTitle', + { + defaultMessage: 'Signal detection frequency', + } +); + +export const ALL_OTHERS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.allOthersGroupingLabel', + { + defaultMessage: 'All others', + } +); + +export const VIEW_SIGNALS = i18n.translate( + 'xpack.siem.detectionEngine.signals.histogram.viewSignalsButtonLabel', + { + defaultMessage: 'View signals', + } +); + +export const SHOWING_SIGNALS = ( + totalSignalsFormatted: string, + totalSignals: number, + modifier: string +) => + i18n.translate('xpack.siem.detectionEngine.signals.histogram.showingSignalsTitle', { + values: { totalSignalsFormatted, totalSignals, modifier }, + defaultMessage: + 'Showing: {modifier}{totalSignalsFormatted} {totalSignals, plural, =1 {signal} other {signals}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts new file mode 100644 index 00000000000000..4eb10852450ade --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_histogram_panel/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SignalsHistogramOption { + text: string; + value: string; +} + +export interface HistogramData { + x: number; + y: number; + g: string; +} + +export interface SignalsAggregation { + signalsByGrouping: { + buckets: SignalsGroupBucket[]; + }; +} + +export interface SignalsBucket { + key_as_string: string; + key: number; + doc_count: number; +} + +export interface SignalsGroupBucket { + key: string; + signals: { + buckets: SignalsBucket[]; + }; +} + +export interface SignalsTotal { + value: number; + relation: string; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx index a90022d4a34cea..fc1110e3828473 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/index.tsx @@ -9,7 +9,7 @@ import { FormattedRelative } from '@kbn/i18n/react'; import React, { useState, useEffect } from 'react'; import { useQuerySignals } from '../../../../containers/detection_engine/signals/use_query'; -import { buildlastSignalsQuery } from './query.dsl'; +import { buildLastSignalsQuery } from './query.dsl'; import { Aggs } from './types'; interface SignalInfo { @@ -26,14 +26,7 @@ export const useSignalInfo = ({ ruleId = null }: SignalInfo): Return => { ); - let query = ''; - try { - query = JSON.stringify(buildlastSignalsQuery(ruleId)); - } catch { - query = ''; - } - - const [loading, signals] = useQuerySignals(query); + const [loading, signals] = useQuerySignals(buildLastSignalsQuery(ruleId)); useEffect(() => { if (signals != null) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts index 0b14aa17a94500..8cb07a4f8e6b55 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals_info/query.dsl.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export const buildlastSignalsQuery = (ruleId: string | undefined | null) => { +export const buildLastSignalsQuery = (ruleId: string | undefined | null) => { const queryFilter = [ { bool: { should: [{ match: { 'signal.status': 'open' } }], minimum_should_match: 1 }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx new file mode 100644 index 00000000000000..bbaccb7882484b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { noop } from 'lodash/fp'; +import React, { useEffect, useReducer, Dispatch, createContext, useContext } from 'react'; + +import { usePrivilegeUser } from '../../../../containers/detection_engine/signals/use_privilege_user'; +import { useSignalIndex } from '../../../../containers/detection_engine/signals/use_signal_index'; +import { useKibana } from '../../../../lib/kibana'; + +export interface State { + canUserCRUD: boolean | null; + hasIndexManage: boolean | null; + hasIndexWrite: boolean | null; + hasManageApiKey: boolean | null; + isSignalIndexExists: boolean | null; + isAuthenticated: boolean | null; + loading: boolean; + signalIndexName: string | null; +} + +const initialState: State = { + canUserCRUD: null, + hasIndexManage: null, + hasIndexWrite: null, + hasManageApiKey: null, + isSignalIndexExists: null, + isAuthenticated: null, + loading: true, + signalIndexName: null, +}; + +export type Action = + | { type: 'updateLoading'; loading: boolean } + | { + type: 'updateHasManageApiKey'; + hasManageApiKey: boolean | null; + } + | { + type: 'updateHasIndexManage'; + hasIndexManage: boolean | null; + } + | { + type: 'updateHasIndexWrite'; + hasIndexWrite: boolean | null; + } + | { + type: 'updateIsSignalIndexExists'; + isSignalIndexExists: boolean | null; + } + | { + type: 'updateIsAuthenticated'; + isAuthenticated: boolean | null; + } + | { + type: 'updateCanUserCRUD'; + canUserCRUD: boolean | null; + } + | { + type: 'updateSignalIndexName'; + signalIndexName: string | null; + }; + +export const userInfoReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'updateLoading': { + return { + ...state, + loading: action.loading, + }; + } + case 'updateHasIndexManage': { + return { + ...state, + hasIndexManage: action.hasIndexManage, + }; + } + case 'updateHasIndexWrite': { + return { + ...state, + hasIndexWrite: action.hasIndexWrite, + }; + } + case 'updateHasManageApiKey': { + return { + ...state, + hasManageApiKey: action.hasManageApiKey, + }; + } + case 'updateIsSignalIndexExists': { + return { + ...state, + isSignalIndexExists: action.isSignalIndexExists, + }; + } + case 'updateIsAuthenticated': { + return { + ...state, + isAuthenticated: action.isAuthenticated, + }; + } + case 'updateCanUserCRUD': { + return { + ...state, + canUserCRUD: action.canUserCRUD, + }; + } + case 'updateSignalIndexName': { + return { + ...state, + signalIndexName: action.signalIndexName, + }; + } + default: + return state; + } +}; + +const StateUserInfoContext = createContext<[State, Dispatch]>([initialState, () => noop]); + +const useUserData = () => useContext(StateUserInfoContext); + +interface ManageUserInfoProps { + children: React.ReactNode; +} + +export const ManageUserInfo = ({ children }: ManageUserInfoProps) => ( + + {children} + +); + +export const useUserInfo = (): State => { + const [ + { + canUserCRUD, + hasIndexManage, + hasIndexWrite, + hasManageApiKey, + isSignalIndexExists, + isAuthenticated, + loading, + signalIndexName, + }, + dispatch, + ] = useUserData(); + const { + loading: privilegeLoading, + isAuthenticated: isApiAuthenticated, + hasIndexManage: hasApiIndexManage, + hasIndexWrite: hasApiIndexWrite, + hasManageApiKey: hasApiManageApiKey, + } = usePrivilegeUser(); + const [ + indexNameLoading, + isApiSignalIndexExists, + apiSignalIndexName, + createSignalIndex, + ] = useSignalIndex(); + + const uiCapabilities = useKibana().services.application.capabilities; + const capabilitiesCanUserCRUD: boolean = + typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false; + + useEffect(() => { + if (loading !== privilegeLoading || indexNameLoading) { + dispatch({ type: 'updateLoading', loading: privilegeLoading || indexNameLoading }); + } + }, [loading, privilegeLoading, indexNameLoading]); + + useEffect(() => { + if (hasIndexManage !== hasApiIndexManage && hasApiIndexManage != null) { + dispatch({ type: 'updateHasIndexManage', hasIndexManage: hasApiIndexManage }); + } + }, [hasIndexManage, hasApiIndexManage]); + + useEffect(() => { + if (hasIndexWrite !== hasApiIndexWrite && hasApiIndexWrite != null) { + dispatch({ type: 'updateHasIndexWrite', hasIndexWrite: hasApiIndexWrite }); + } + }, [hasIndexWrite, hasApiIndexWrite]); + + useEffect(() => { + if (hasManageApiKey !== hasApiManageApiKey && hasApiManageApiKey != null) { + dispatch({ type: 'updateHasManageApiKey', hasManageApiKey: hasApiManageApiKey }); + } + }, [hasManageApiKey, hasApiManageApiKey]); + + useEffect(() => { + if (isSignalIndexExists !== isApiSignalIndexExists && isApiSignalIndexExists != null) { + dispatch({ type: 'updateIsSignalIndexExists', isSignalIndexExists: isApiSignalIndexExists }); + } + }, [isSignalIndexExists, isApiSignalIndexExists]); + + useEffect(() => { + if (isAuthenticated !== isApiAuthenticated && isApiAuthenticated != null) { + dispatch({ type: 'updateIsAuthenticated', isAuthenticated: isApiAuthenticated }); + } + }, [isAuthenticated, isApiAuthenticated]); + + useEffect(() => { + if (canUserCRUD !== capabilitiesCanUserCRUD && capabilitiesCanUserCRUD != null) { + dispatch({ type: 'updateCanUserCRUD', canUserCRUD: capabilitiesCanUserCRUD }); + } + }, [canUserCRUD, capabilitiesCanUserCRUD]); + + useEffect(() => { + if (signalIndexName !== apiSignalIndexName && apiSignalIndexName != null) { + dispatch({ type: 'updateSignalIndexName', signalIndexName: apiSignalIndexName }); + } + }, [signalIndexName, apiSignalIndexName]); + + useEffect(() => { + if ( + isAuthenticated && + hasIndexManage && + isSignalIndexExists != null && + !isSignalIndexExists && + createSignalIndex != null + ) { + createSignalIndex(); + } + }, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasIndexManage]); + + return { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasIndexManage, + hasIndexWrite, + hasManageApiKey, + signalIndexName, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 8e5c3e9f131180..e638cf89e77bbe 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiSpacer, EuiPanel, EuiLoadingContent } from '@elastic/eui'; -import React from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; import { StickyContainer } from 'react-sticky'; +import { connect } from 'react-redux'; +import { ActionCreator } from 'typescript-fsa'; import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { SiemSearchBar } from '../../components/search_bar'; @@ -16,26 +18,59 @@ import { GlobalTime } from '../../containers/global_time'; import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source'; import { SpyRoute } from '../../utils/route/spy_routes'; -import { SignalsTable } from './components/signals'; -import * as signalsI18n from './components/signals/translations'; -import { SignalsCharts } from './components/signals_chart'; +import { Query } from '../../../../../../../src/plugins/data/common/query'; +import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; +import { State } from '../../store'; +import { inputsSelectors } from '../../store/inputs'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { InputsModelId } from '../../store/inputs/constants'; +import { InputsRange } from '../../store/inputs/model'; import { useSignalInfo } from './components/signals_info'; +import { SignalsTable } from './components/signals'; +import { NoWriteSignalsCallOut } from './components/no_write_signals_callout'; +import { SignalsHistogramPanel } from './components/signals_histogram_panel'; +import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; +import { useUserInfo } from './components/user_info'; import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; -import { HeaderSection } from '../../components/header_section'; -interface DetectionEngineComponentProps { - loading: boolean; - isSignalIndexExists: boolean | null; - isUserAuthenticated: boolean | null; - signalsIndex: string | null; +interface ReduxProps { + filters: esFilters.Filter[]; + query: Query; } -export const DetectionEngineComponent = React.memo( - ({ loading, isSignalIndexExists, isUserAuthenticated, signalsIndex }) => { +export interface DispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; +} + +type DetectionEngineComponentProps = ReduxProps & DispatchProps; + +const DetectionEngineComponent = React.memo( + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated: isUserAuthenticated, + canUserCRUD, + signalIndexName, + hasIndexWrite, + } = useUserInfo(); + const [lastSignals] = useSignalInfo({}); + + const updateDateRangeCallback = useCallback( + (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, + [setAbsoluteRangeDatePicker] + ); + if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( @@ -54,6 +89,7 @@ export const DetectionEngineComponent = React.memo + {hasIndexWrite != null && !hasIndexWrite && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -61,7 +97,6 @@ export const DetectionEngineComponent = React.memo - - - - - {({ to, from }) => - !loading ? ( - isSignalIndexExists && ( - - ) - ) : ( - - - - - ) - } + {({ to, from }) => ( + <> + + + + + + + )} @@ -108,10 +152,28 @@ export const DetectionEngineComponent = React.memo - ); } ); DetectionEngineComponent.displayName = 'DetectionEngineComponent'; + +const makeMapStateToProps = () => { + const getGlobalInputs = inputsSelectors.globalSelector(); + return (state: State) => { + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + + return { + query, + filters, + }; + }; +}; + +export const DetectionEngine = connect(makeMapStateToProps, { + setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, +})(DetectionEngineComponent); + +DetectionEngine.displayName = 'DetectionEngine'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index e8a2c98a94a562..c4e83429aebdbf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -4,70 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; -import { useSignalIndex } from '../../containers/detection_engine/signals/use_signal_index'; -import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user'; - import { CreateRuleComponent } from './rules/create'; -import { DetectionEngineComponent } from './detection_engine'; +import { DetectionEngine } from './detection_engine'; import { EditRuleComponent } from './rules/edit'; -import { RuleDetailsComponent } from './rules/details'; +import { RuleDetails } from './rules/details'; import { RulesComponent } from './rules'; +import { ManageUserInfo } from './components/user_info'; const detectionEnginePath = `/:pageName(detection-engine)`; type Props = Partial> & { url: string }; -export const DetectionEngineContainer = React.memo(() => { - const [privilegeLoading, isAuthenticated, hasWrite] = usePrivilegeUser(); - const [ - indexNameLoading, - isSignalIndexExists, - signalIndexName, - createSignalIndex, - ] = useSignalIndex(); - - useEffect(() => { - if ( - isAuthenticated && - hasWrite && - isSignalIndexExists != null && - !isSignalIndexExists && - createSignalIndex != null - ) { - createSignalIndex(); - } - }, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasWrite]); - - return ( +export const DetectionEngineContainer = React.memo(() => ( + - + + + + + + + + + + + + + - {isSignalIndexExists && isAuthenticated && ( - <> - - - - - - - - - - - - - - )} - ( @@ -75,6 +43,6 @@ export const DetectionEngineContainer = React.memo(() => { )} /> - ); -}); + +)); DetectionEngineContainer.displayName = 'DetectionEngineContainer'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index f176109b1d7a53..469745262d9446 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -17,7 +17,7 @@ import { import { Action } from './reducer'; export const editRuleAction = (rule: Rule, history: H.History) => { - history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/${rule.id}/edit`); + history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`); }; export const runRuleAction = () => {}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 42c4bb1d0ef95d..95b9c9324894fb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -68,111 +68,121 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ }, ]; +type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType; + // Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes? export const getColumns = ( dispatch: React.Dispatch, - history: H.History -): Array | EuiTableActionsColumnType> => [ - { - field: 'rule', - name: i18n.COLUMN_RULE, - render: (value: TableData['rule']) => {value.name}, - truncateText: true, - width: '24%', - }, - { - field: 'method', - name: i18n.COLUMN_METHOD, - truncateText: true, - }, - { - field: 'severity', - name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), - truncateText: true, - }, - { - field: 'lastCompletedRun', - name: i18n.COLUMN_LAST_COMPLETE_RUN, - render: (value: TableData['lastCompletedRun']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); + history: H.History, + hasNoPermissions: boolean +): RulesColumns[] => { + const cols: RulesColumns[] = [ + { + field: 'rule', + name: i18n.COLUMN_RULE, + render: (value: TableData['rule']) => {value.name}, + truncateText: true, + width: '24%', }, - sortable: true, - truncateText: true, - width: '16%', - }, - { - field: 'lastResponse', - name: i18n.COLUMN_LAST_RESPONSE, - render: (value: TableData['lastResponse']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - <> - {value.type === 'Fail' ? ( - - {value.type} - - ) : ( - {value.type} - )} - - ); + { + field: 'method', + name: i18n.COLUMN_METHOD, + truncateText: true, }, - truncateText: true, - }, - { - field: 'tags', - name: i18n.COLUMN_TAGS, - render: (value: TableData['tags']) => ( -
- <> - {value.map((tag, i) => ( - - {tag} - - ))} - -
- ), - truncateText: true, - width: '20%', - }, - { - align: 'center', - field: 'activate', - name: i18n.COLUMN_ACTIVATE, - render: (value: TableData['activate'], item: TableData) => ( - - ), - sortable: true, - width: '85px', - }, - { - actions: getActions(dispatch, history), - width: '40px', - } as EuiTableActionsColumnType, -]; + { + field: 'severity', + name: i18n.COLUMN_SEVERITY, + render: (value: TableData['severity']) => ( + + {value} + + ), + truncateText: true, + }, + { + field: 'lastCompletedRun', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: TableData['lastCompletedRun']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: true, + truncateText: true, + width: '16%', + }, + { + field: 'lastResponse', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: TableData['lastResponse']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + <> + {value.type === 'Fail' ? ( + + {value.type} + + ) : ( + {value.type} + )} + + ); + }, + truncateText: true, + }, + { + field: 'tags', + name: i18n.COLUMN_TAGS, + render: (value: TableData['tags']) => ( +
+ <> + {value.map((tag, i) => ( + + {tag} + + ))} + +
+ ), + truncateText: true, + width: '20%', + }, + { + align: 'center', + field: 'activate', + name: i18n.COLUMN_ACTIVATE, + render: (value: TableData['activate'], item: TableData) => ( + + ), + sortable: true, + width: '85px', + }, + ]; + const actions: RulesColumns[] = [ + { + actions: getActions(dispatch, history), + width: '40px', + } as EuiTableActionsColumnType, + ]; + + return hasNoPermissions ? cols : [...cols, ...actions]; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index 1909b75a85835e..f5d39553142429 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -13,7 +13,7 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] id: rule.id, rule_id: rule.rule_id, rule: { - href: `#/detection-engine/rules/${encodeURIComponent(rule.id)}`, + href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`, name: rule.name, status: 'Status Placeholder', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 060f8baccc3b73..e900058b6c53cf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -11,7 +11,7 @@ import { EuiLoadingContent, EuiSpacer, } from '@elastic/eui'; -import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { useHistory } from 'react-router-dom'; import uuid from 'uuid'; @@ -60,7 +60,11 @@ const initialState: State = { * * Delete * * Import/Export */ -export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importCompleteToggle => { +export const AllRules = React.memo<{ + hasNoPermissions: boolean; + importCompleteToggle: boolean; + loading: boolean; +}>(({ hasNoPermissions, importCompleteToggle, loading }) => { const [ { exportPayload, @@ -111,6 +115,15 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp }); }, [rulesData]); + const euiBasicTableSelectionProps = useMemo( + () => ({ + selectable: (item: TableData) => !item.isLoading, + onSelectionChange: (selected: TableData[]) => + dispatch({ type: 'setSelected', selectedItems: selected }), + }), + [] + ); + return ( <> (importComp {i18n.SELECTED_RULES(selectedItems.length)} - - {i18n.BATCH_ACTIONS} - + {!hasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} (importComp { @@ -204,14 +219,12 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp totalItemCount: pagination.total, pageSizeOptions: [5, 10, 20], }} - selection={{ - selectable: (item: TableData) => !item.isLoading, - onSelectionChange: (selected: TableData[]) => - dispatch({ type: 'setSelected', selectedItems: selected }), - }} sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }} + selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} /> - {isLoading && } + {(isLoading || loading) && ( + + )} )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index f090f6d97eaf9d..b3cc81b5cdfcfc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -4,9 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiSpacer, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react'; +import styled from 'styled-components'; import * as RuleI18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; @@ -17,11 +26,27 @@ interface AddItemProps { dataTestSubj: string; idAria: string; isDisabled: boolean; + validate?: (args: unknown) => boolean; } -export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { +const MyEuiFormRow = styled(EuiFormRow)` + .euiFormRow__labelWrapper { + .euiText { + padding-right: 32px; + } + } +`; + +export const AddItem = ({ + addText, + dataTestSubj, + field, + idAria, + isDisabled, + validate, +}: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - // const [items, setItems] = useState(['']); const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1); const inputsRef = useRef([]); @@ -29,7 +54,8 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + field.setValue(newValues.length === 0 ? [''] : newValues); inputsRef.current = [ ...inputsRef.current.slice(0, index), ...inputsRef.current.slice(index + 1), @@ -46,11 +72,7 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad const addItem = useCallback(() => { const values = field.value as string[]; - if (!isEmpty(values) && values[values.length - 1]) { - field.setValue([...values, '']); - } else if (isEmpty(values)) { - field.setValue(['']); - } + field.setValue([...values, '']); }, [field]); const updateItem = useCallback( @@ -58,22 +80,7 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad event.persist(); const values = field.value as string[]; const value = event.target.value; - if (isEmpty(value)) { - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); - inputsRef.current = [ - ...inputsRef.current.slice(0, index), - ...inputsRef.current.slice(index + 1), - ]; - setHaveBeenKeyboardDeleted(inputsRef.current.length - 1); - inputsRef.current = inputsRef.current.map((ref, i) => { - if (i >= index && inputsRef.current[index] != null) { - ref.value = 're-render'; - } - return ref; - }); - } else { - field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); - } + field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); }, [field] ); @@ -104,11 +111,11 @@ export const AddItem = ({ addText, dataTestSubj, field, idAria, isDisabled }: Ad const values = field.value as string[]; return ( - - + + setShowValidation(true)} + onChange={e => updateItem(e, index)} + fullWidth + {...euiFieldProps} + /> + + removeItem(index)} aria-label={RuleI18n.DELETE} /> - } - onChange={e => updateItem(e, index)} - compressed - fullWidth - {...euiFieldProps} - /> + + + {values.length - 1 !== index && }
); })} - + {addText} - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx new file mode 100644 index 00000000000000..09d0c1131ea10d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBadge, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiLink, + EuiText, + EuiListGroup, +} from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { isEmpty } from 'lodash/fp'; +import React from 'react'; +import styled from 'styled-components'; + +import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; + +import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; + +import { FilterLabel } from './filter_label'; +import * as i18n from './translations'; +import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types'; + +const EuiBadgeWrap = styled(EuiBadge)` + .euiBadge__text { + white-space: pre-wrap !important; + } +`; + +export const buildQueryBarDescription = ({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, +}: BuildQueryBarDescription): ListItems[] => { + let items: ListItems[] = []; + if (!isEmpty(filters)) { + filterManager.setFilters(filters); + items = [ + ...items, + { + title: <>{i18n.FILTERS_LABEL} , + description: ( + + {filterManager.getFilters().map((filter, index) => ( + + + {indexPatterns != null ? ( + + ) : ( + + )} + + + ))} + + ), + }, + ]; + } + if (!isEmpty(query.query)) { + items = [ + ...items, + { + title: <>{i18n.QUERY_LABEL} , + description: <>{query.query} , + }, + ]; + } + if (!isEmpty(savedId)) { + items = [ + ...items, + { + title: <>{i18n.SAVED_ID_LABEL} , + description: <>{savedId} , + }, + ]; + } + return items; +}; + +const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` + .euiFlexItem { + margin-bottom: 0px; + } +`; + +const MyEuiListGroup = styled(EuiListGroup)` + padding: 0px; + .euiListGroupItem__button { + padding: 0px; + } +`; + +export const buildThreatsDescription = ({ + label, + threats, +}: BuildThreatsDescription): ListItems[] => { + if (threats.length > 0) { + return [ + { + title: label, + description: ( + + {threats.map((threat, index) => { + const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); + return ( + + +
+ + {tactic != null ? tactic.text : ''} + +
+ { + const myTechnique = techniquesOptions.find(t => t.name === technique.name); + return { + label: myTechnique != null ? myTechnique.label : '', + href: technique.reference, + target: '_blank', + }; + })} + /> +
+
+ ); + })} +
+ ), + }, + ]; + } + return []; +}; + +export const buildStringArrayDescription = ( + label: string, + field: string, + values: string[] +): ListItems[] => { + if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + return [ + { + title: label, + description: ( + + {values.map((val: string) => + isEmpty(val) ? null : ( + + {val} + + ) + )} + + ), + }, + ]; + } + return []; +}; + +export const buildSeverityDescription = (label: string, value: string): ListItems[] => { + return [ + { + title: label, + description: ( + + {value} + + ), + }, + ]; +}; + +export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => { + if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + return [ + { + title: label, + description: ( + ({ + label: val, + href: val, + iconType: 'link', + size: 'xs', + target: '_blank', + }))} + /> + ), + }, + ]; + } + return []; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index a05f43579e6694..af4f93c0fdbcd2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -4,19 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiDescriptionList, - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiTextArea, - EuiLink, - EuiText, - EuiListGroup, -} from '@elastic/eui'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; import { isEmpty, chunk, get, pick } from 'lodash/fp'; -import React, { memo, ReactNode, useState } from 'react'; +import React, { memo, useState } from 'react'; import styled from 'styled-components'; import { @@ -25,13 +15,19 @@ import { FilterManager, Query, } from '../../../../../../../../../../src/plugins/data/public'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/search_super_select/translations'; import { useKibana } from '../../../../../lib/kibana'; -import { FilterLabel } from './filter_label'; -import { FormSchema } from '../shared_imports'; -import * as I18n from './translations'; - import { IMitreEnterpriseAttack } from '../../types'; -import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; +import { FieldValueTimeline } from '../pick_timeline'; +import { FormSchema } from '../shared_imports'; +import { ListItems } from './types'; +import { + buildQueryBarDescription, + buildSeverityDescription, + buildStringArrayDescription, + buildThreatsDescription, + buildUrlsDescription, +} from './helpers'; interface StepRuleDescriptionProps { direction?: 'row' | 'column'; @@ -40,29 +36,10 @@ interface StepRuleDescriptionProps { schema: FormSchema; } -const EuiBadgeWrap = styled(EuiBadge)` - .euiBadge__text { - white-space: pre-wrap !important; - } -`; - const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>` ${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')}; `; -const MyEuiListGroup = styled(EuiListGroup)` - padding: 0px; - .euiListGroupItem__button { - padding: 0px; - } -`; - -const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` - .euiFlexItem { - margin-bottom: 0px; - } -`; - const MyEuiTextArea = styled(EuiTextArea)` max-width: 100%; height: 80px; @@ -87,9 +64,9 @@ const StepRuleDescriptionComponent: React.FC = ({ ); return ( - {chunk(Math.ceil(listItems.length / 2), listItems).map((chunckListItems, index) => ( + {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( - + ))} @@ -98,11 +75,6 @@ const StepRuleDescriptionComponent: React.FC = ({ export const StepRuleDescription = memo(StepRuleDescriptionComponent); -interface ListItems { - title: NonNullable; - description: NonNullable; -} - const buildListItems = ( data: unknown, schema: FormSchema, @@ -130,103 +102,23 @@ const getDescriptionItem = ( filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => { - if (field === 'useIndicesConfig') { - return []; - } else if (field === 'queryBar') { + if (field === 'queryBar') { const filters = get('queryBar.filters', value) as esFilters.Filter[]; const query = get('queryBar.query', value) as Query; const savedId = get('queryBar.saved_id', value); - let items: ListItems[] = []; - if (!isEmpty(filters)) { - filterManager.setFilters(filters); - items = [ - ...items, - { - title: <>{I18n.FILTERS_LABEL}, - description: ( - - {filterManager.getFilters().map((filter, index) => ( - - - {indexPatterns != null ? ( - - ) : ( - - )} - - - ))} - - ), - }, - ]; - } - if (!isEmpty(query.query)) { - items = [ - ...items, - { - title: <>{I18n.QUERY_LABEL}, - description: <>{query.query}, - }, - ]; - } - if (!isEmpty(savedId)) { - items = [ - ...items, - { - title: <>{I18n.SAVED_ID_LABEL}, - description: <>{savedId}, - }, - ]; - } - return items; + return buildQueryBarDescription({ + field, + filters, + filterManager, + query, + savedId, + indexPatterns, + }); } else if (field === 'threats') { const threats: IMitreEnterpriseAttack[] = get(field, value).filter( (threat: IMitreEnterpriseAttack) => threat.tactic.name !== 'none' ); - if (threats.length > 0) { - return [ - { - title: label, - description: ( - - {threats.map((threat, index) => { - const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); - return ( - - -
- - {tactic != null ? tactic.text : ''} - -
- { - const myTechnique = techniquesOptions.find( - t => t.name === technique.name - ); - return { - label: myTechnique != null ? myTechnique.label : '', - href: technique.reference, - target: '_blank', - }; - })} - /> -
-
- ); - })} -
- ), - }, - ]; - } - return []; + return buildThreatsDescription({ label, threats }); } else if (field === 'description') { return [ { @@ -234,27 +126,31 @@ const getDescriptionItem = ( description: , }, ]; + } else if (field === 'references') { + const urls: string[] = get(field, value); + return buildUrlsDescription(label, urls); } else if (Array.isArray(get(field, value))) { const values: string[] = get(field, value); - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { - return [ - { - title: label, - description: ( - - {values.map((val: string) => - isEmpty(val) ? null : ( - - {val} - - ) - )} - - ), - }, - ]; - } - return []; + return buildStringArrayDescription(label, field, values); + } else if (field === 'severity') { + const val: string = get(field, value); + return buildSeverityDescription(label, val); + } else if (field === 'timeline') { + const timeline = get(field, value) as FieldValueTimeline; + return [ + { + title: label, + description: timeline.title ?? DEFAULT_TIMELINE_TITLE, + }, + ]; + } else if (field === 'riskScore') { + const description: string = get(field, value); + return [ + { + title: label, + description, + }, + ]; } const description: string = get(field, value); if (!isEmpty(description)) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx index 0995e0e9166527..9695fd21067eec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx @@ -11,7 +11,7 @@ export const FILTERS_LABEL = i18n.translate('xpack.siem.detectionEngine.createRu }); export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.QueryLabel', { - defaultMessage: 'Query', + defaultMessage: 'Custom query', }); export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts new file mode 100644 index 00000000000000..d32fbcd725d12f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ReactNode } from 'react'; + +import { + IIndexPattern, + esFilters, + FilterManager, + Query, +} from '../../../../../../../../../../src/plugins/data/public'; +import { IMitreEnterpriseAttack } from '../../types'; + +export interface ListItems { + title: NonNullable; + description: NonNullable; +} + +export interface BuildQueryBarDescription { + field: string; + filters: esFilters.Filter[]; + filterManager: FilterManager; + query: Query; + savedId: string; + indexPatterns?: IIndexPattern; +} + +export interface BuildThreatsDescription { + label: string; + threats: IMitreEnterpriseAttack[]; +} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts new file mode 100644 index 00000000000000..1202fe54ad194f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/helpers.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEmpty } from 'lodash/fp'; + +import { IMitreAttack } from '../../types'; + +export const isMitreAttackInvalid = ( + tacticName: string | null | undefined, + techniques: IMitreAttack[] | null | undefined +) => { + if (isEmpty(tacticName) || (tacticName !== 'none' && isEmpty(techniques))) { + return true; + } + return false; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index a777506ee12ae5..2c19e99e901140 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -8,27 +8,30 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiFormRow, - EuiSelect, + EuiSuperSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiComboBox, - EuiFormControlLayout, + EuiText, } from '@elastic/eui'; import { isEmpty, kebabCase, camelCase } from 'lodash/fp'; -import React, { ChangeEvent, useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; -import * as RuleI18n from '../../translations'; +import * as Rulei18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; -import * as I18n from './translations'; +import { threatsDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; +import { isMitreAttackInvalid } from './helpers'; +import * as i18n from './translations'; -const MyEuiFormControlLayout = styled(EuiFormControlLayout)` - &.euiFormControlLayout--compressed { - height: fit-content !important; - } +const MitreContainer = styled.div` + margin-top: 16px; +`; +const MyEuiSuperSelect = styled(EuiSuperSelect)` + width: 280px; `; interface AddItemProps { field: FieldHook; @@ -38,12 +41,18 @@ interface AddItemProps { } export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + if (isEmpty(newValues)) { + field.setValue(threatsDefault); + } else { + field.setValue(newValues); + } }, [field] ); @@ -61,9 +70,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI }, [field]); const updateTactic = useCallback( - (index: number, event: ChangeEvent) => { + (index: number, value: string) => { const values = field.value as IMitreEnterpriseAttack[]; - const { id, reference, name } = tacticsOptions.find(t => t.value === event.target.value) || { + const { id, reference, name } = tacticsOptions.find(t => t.value === value) || { id: '', name: '', reference: '', @@ -97,75 +106,105 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const values = field.value as IMitreEnterpriseAttack[]; + const getSelectTactic = (tacticName: string, index: number, disabled: boolean) => ( + {i18n.TACTIC_PLACEHOLDER}, + value: 'none', + disabled, + }, + ] + : []), + ...tacticsOptions.map(t => ({ + inputDisplay: <>{t.text}, + value: t.value, + disabled, + })), + ]} + aria-label="" + onChange={updateTactic.bind(null, index)} + fullWidth={false} + valueOfSelected={camelCase(tacticName)} + /> + ); + + const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => { + const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques); + return ( + + + t.tactics.includes(kebabCase(item.tactic.name)))} + selectedOptions={item.techniques} + onChange={updateTechniques.bind(null, index)} + isDisabled={disabled || item.tactic.name === 'none'} + fullWidth={true} + isInvalid={showValidation && invalid} + onBlur={() => setShowValidation(true)} + /> + {showValidation && invalid && ( + +

{errorMessage}

+
+ )} +
+ + removeItem(index)} + aria-label={Rulei18n.DELETE} + /> + +
+ ); + }; + return ( - - <> - {values.map((item, index) => { - const euiSelectFieldProps = { - disabled: isDisabled, - }; - return ( -
- - - ({ text: t.text, value: t.value })), - ]} - aria-label="" - onChange={updateTactic.bind(null, index)} - prepend={I18n.TACTIC} - compressed - fullWidth={false} - value={camelCase(item.tactic.name)} - {...euiSelectFieldProps} - /> - - - - - t.tactics.includes(kebabCase(item.tactic.name)) - )} - selectedOptions={item.techniques} - onChange={updateTechniques.bind(null, index)} - isDisabled={isDisabled} - fullWidth={true} - /> - - - - removeItem(index)} - aria-label={RuleI18n.DELETE} - /> - - - {values.length - 1 !== index && } -
- ); - })} - - {I18n.ADD_MITRE_ATTACK} - - -
+ + {values.map((item, index) => ( +
+ + + {index === 0 ? ( + + <>{getSelectTactic(item.tactic.name, index, isDisabled)} + + ) : ( + getSelectTactic(item.tactic.name, index, isDisabled) + )} + + + {index === 0 ? ( + + <>{getSelectTechniques(item, index, isDisabled)} + + ) : ( + getSelectTechniques(item, index, isDisabled) + )} + + + {values.length - 1 !== index && } +
+ ))} + + {i18n.ADD_MITRE_ATTACK} + +
); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts index 22ee6cc3ef911f..557e91691b6c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts @@ -7,18 +7,18 @@ import { i18n } from '@kbn/i18n'; export const TACTIC = i18n.translate('xpack.siem.detectionEngine.mitreAttack.tacticsDescription', { - defaultMessage: 'Tactic', + defaultMessage: 'tactic', }); -export const TECHNIQUES = i18n.translate( +export const TECHNIQUE = i18n.translate( 'xpack.siem.detectionEngine.mitreAttack.techniquesDescription', { - defaultMessage: 'Techniques', + defaultMessage: 'technique', } ); export const ADD_MITRE_ATTACK = i18n.translate('xpack.siem.detectionEngine.mitreAttack.addTitle', { - defaultMessage: 'Add MITRE ATT&CK threat', + defaultMessage: 'Add MITRE ATT&CK\\u2122 threat', }); export const TECHNIQUES_PLACEHOLDER = i18n.translate( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx new file mode 100644 index 00000000000000..873e0c2184c61d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFormRow } from '@elastic/eui'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; + +export interface FieldValueTimeline { + id: string | null; + title: string | null; +} + +interface QueryBarDefineRuleProps { + dataTestSubj: string; + field: FieldHook; + idAria: string; + isDisabled: boolean; +} + +export const PickTimeline = ({ + dataTestSubj, + field, + idAria, + isDisabled = false, +}: QueryBarDefineRuleProps) => { + const [timelineId, setTimelineId] = useState(null); + const [timelineTitle, setTimelineTitle] = useState(null); + + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + useEffect(() => { + const { id, title } = field.value as FieldValueTimeline; + if (timelineTitle !== title && timelineId !== id) { + setTimelineId(id); + setTimelineTitle(title); + } + }, [field.value]); + + const handleOnTimelineChange = useCallback( + (title: string, id: string | null) => { + if (id === null) { + field.setValue({ id, title: null }); + } else if (timelineTitle !== title && timelineId !== id) { + field.setValue({ id, title }); + } + }, + [field] + ); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index c294ec24c4cb70..3e39beb6e61b78 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -6,7 +6,7 @@ import { EuiFormRow, EuiMutationObserver } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Subscription } from 'rxjs'; import styled from 'styled-components'; @@ -19,11 +19,18 @@ import { SavedQueryTimeFilter, } from '../../../../../../../../../../src/plugins/data/public'; +import { BrowserFields } from '../../../../../containers/source'; +import { OpenTimelineModal } from '../../../../../components/open_timeline/open_timeline_modal'; +import { ActionTimelineToShow } from '../../../../../components/open_timeline/types'; import { QueryBar } from '../../../../../components/query_bar'; +import { buildGlobalQuery } from '../../../../../components/timeline/helpers'; +import { getDataProviderFilter } from '../../../../../components/timeline/query_bar'; +import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury'; import { useKibana } from '../../../../../lib/kibana'; +import { TimelineModel } from '../../../../../store/timeline/model'; import { useSavedQueryServices } from '../../../../../utils/saved_query_services'; - import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; +import * as i18n from './translations'; export interface FieldValueQueryBar { filters: esFilters.Filter[]; @@ -31,11 +38,14 @@ export interface FieldValueQueryBar { saved_id: string | null; } interface QueryBarDefineRuleProps { + browserFields: BrowserFields; dataTestSubj: string; field: FieldHook; idAria: string; isLoading: boolean; indexPattern: IIndexPattern; + onCloseTimelineSearch: () => void; + openTimelineSearch: boolean; resizeParentContainer?: (height: number) => void; } @@ -56,14 +66,18 @@ const StyledEuiFormRow = styled(EuiFormRow)` // TODO need to add disabled in the SearchBar export const QueryBarDefineRule = ({ + browserFields, dataTestSubj, field, idAria, indexPattern, isLoading = false, + onCloseTimelineSearch, + openTimelineSearch = false, resizeParentContainer, }: QueryBarDefineRuleProps) => { const [originalHeight, setOriginalHeight] = useState(-1); + const [loadingTimeline, setLoadingTimeline] = useState(false); const [savedQuery, setSavedQuery] = useState(null); const [queryDraft, setQueryDraft] = useState({ query: '', language: 'kuery' }); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); @@ -168,6 +182,38 @@ export const QueryBarDefineRule = ({ [field.value] ); + const onCloseTimelineModal = useCallback(() => { + setLoadingTimeline(true); + onCloseTimelineSearch(); + }, [onCloseTimelineSearch]); + + const onOpenTimeline = useCallback( + (timeline: TimelineModel) => { + setLoadingTimeline(false); + const newQuery = { + query: timeline.kqlQuery.filterQuery?.kuery?.expression ?? '', + language: timeline.kqlQuery.filterQuery?.kuery?.kind ?? 'kuery', + }; + const dataProvidersDsl = + timeline.dataProviders != null && timeline.dataProviders.length > 0 + ? convertKueryToElasticSearchQuery( + buildGlobalQuery(timeline.dataProviders, browserFields), + indexPattern + ) + : ''; + const newFilters = timeline.filters ?? []; + field.setValue({ + filters: + dataProvidersDsl !== '' + ? [...newFilters, getDataProviderFilter(dataProvidersDsl)] + : newFilters, + query: newQuery, + saved_id: '', + }); + }, + [browserFields, field, indexPattern] + ); + const onMutation = (event: unknown, observer: unknown) => { if (resizeParentContainer != null) { const suggestionContainer = document.getElementById('kbnTypeahead__items'); @@ -189,39 +235,51 @@ export const QueryBarDefineRule = ({ } }; + const actionTimelineToHide = useMemo(() => ['duplicate'], []); + return ( - - + - {mutationRef => ( -
- -
- )} -
-
+ + {mutationRef => ( +
+ +
+ )} +
+ + {openTimelineSearch ? ( + + ) : null} + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx new file mode 100644 index 00000000000000..9b14e4f8599dac --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/translations.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const IMPORT_TIMELINE_MODAL = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineModalTitle', + { + defaultMessage: 'Import query from saved timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx new file mode 100644 index 00000000000000..6ec76bacc2323c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +const ReadOnlyCallOutComponent = () => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + +

{i18n.READ_ONLY_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const ReadOnlyCallOut = memo(ReadOnlyCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts new file mode 100644 index 00000000000000..c3429f43650310 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const READ_ONLY_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.readOnlyCallOutTitle', + { + defaultMessage: 'Rule permissions required', + } +); + +export const READ_ONLY_CALLOUT_MSG = i18n.translate( + 'xpack.siem.detectionEngine.readOnlyCallOutMsg', + { + defaultMessage: + 'You are currently missing the required permissions to create/edit detection engine rule. Please contact your administrator for further assistance.', + } +); + +export const DISMISS_CALLOUT = i18n.translate('xpack.siem.detectionEngine.dismissButton', { + defaultMessage: 'Dismiss', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap index f264dde07c5942..604f86866d5653 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap @@ -11,7 +11,6 @@ exports[`RuleSwitch renders correctly against snapshot 1`] = ` ; id: string; enabled: boolean; + isDisabled?: boolean; isLoading?: boolean; optionLabel?: string; } @@ -42,6 +43,7 @@ export interface RuleSwitchProps { export const RuleSwitchComponent = ({ dispatch, id, + isDisabled, isLoading, enabled, optionLabel, @@ -92,7 +94,7 @@ export const RuleSwitchComponent = ({ data-test-subj="rule-switch" label={optionLabel ?? ''} showLabel={!isEmpty(optionLabel)} - disabled={false} + disabled={isDisabled} checked={myEnabled} onChange={onRuleStateChange} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 2e57ff8ba2c4f0..8097c27cddfe8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; @@ -32,6 +32,10 @@ const StyledEuiFormRow = styled(EuiFormRow)` } `; +const MyEuiSelect = styled(EuiSelect)` + width: auto; +`; + export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: ScheduleItemProps) => { const [timeType, setTimeType] = useState('s'); const [timeVal, setTimeVal] = useState(0); @@ -79,22 +83,33 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu // EUI missing some props const rest = { disabled: isDisabled }; + const label = useMemo( + () => ( + + + {field.label} + + + {field.labelAppend} + + + ), + [field.label, field.labelAppend] + ); return ( } - compressed fullWidth min={0} onChange={onChangeTimeVal} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx index 48ff0d80d0398f..3ec5bf1a12eb0f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/status_icon/index.tsx @@ -27,7 +27,7 @@ const RuleStatusIconStyled = styled.div` const RuleStatusIconComponent: React.FC = ({ name, type }) => { const theme = useEuiTheme(); - const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorDarkestShade; + const color = type === 'passive' ? theme.euiColorLightestShade : theme.euiColorPrimary; return ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts deleted file mode 100644 index 7d6e434bcc8c6c..00000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as I18n from './translations'; - -export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; - -interface SeverityOptionItem { - value: SeverityValue; - text: string; -} - -export const severityOptions: SeverityOptionItem[] = [ - { value: 'low', text: I18n.LOW }, - { value: 'medium', text: I18n.MEDIUM }, - { value: 'high', text: I18n.HIGH }, - { value: 'critical', text: I18n.CRITICAL }, -]; - -export const defaultRiskScoreBySeverity: Record = { - low: 21, - medium: 47, - high: 73, - critical: 99, -}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx new file mode 100644 index 00000000000000..9fb64189ebd1af --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHealth } from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import * as I18n from './translations'; + +export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; + +interface SeverityOptionItem { + value: SeverityValue; + inputDisplay: React.ReactElement; +} + +export const severityOptions: SeverityOptionItem[] = [ + { + value: 'low', + inputDisplay: {I18n.LOW}, + }, + { + value: 'medium', + inputDisplay: {I18n.MEDIUM} , + }, + { + value: 'high', + inputDisplay: {I18n.HIGH} , + }, + { + value: 'critical', + inputDisplay: {I18n.CRITICAL} , + }, +]; + +export const defaultRiskScoreBySeverity: Record = { + low: 21, + medium: 47, + high: 73, + critical: 99, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index c0c5ae77a1960e..328c4a0f960667 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -6,6 +6,14 @@ import { AboutStepRule } from '../../types'; +export const threatsDefault = [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + techniques: [], + }, +]; + export const stepAboutDefaultValue: AboutStepRule = { name: '', description: '', @@ -15,11 +23,9 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - threats: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - techniques: [], - }, - ], + timeline: { + id: null, + title: null, + }, + threats: threatsDefault, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.ts new file mode 100644 index 00000000000000..99b01c8b22974c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/helpers.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; + +const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; + +export const isUrlInvalid = (url: string | null | undefined) => { + if (!isEmpty(url) && url != null && url.match(urlExpression) == null) { + return true; + } + return false; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index e266c0b9ab47d6..8956776dcd3b21 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -7,17 +7,21 @@ import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; import React, { memo, useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; import * as RuleI18n from '../../translations'; -import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { AddItem } from '../add_item_form'; +import { StepRuleDescription } from '../description_step'; +import { AddMitreThreat } from '../mitre'; +import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; + import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; import { stepAboutDefaultValue } from './default_value'; +import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { StepRuleDescription } from '../description_step'; -import { AddMitreThreat } from '../mitre'; +import { PickTimeline } from '../pick_timeline'; const CommonUseField = getUseField({ component: Field }); @@ -25,6 +29,10 @@ interface StepAboutRuleProps extends RuleStepProps { defaultValues?: AboutStepRule | null; } +const TagContainer = styled.div` + margin-top: 16px; +`; + export const StepAboutRule = memo( ({ defaultValues, @@ -90,7 +98,6 @@ export const StepAboutRule = memo( idAria: 'detectionEngineStepAboutRuleName', 'data-test-subj': 'detectionEngineStepAboutRuleName', euiFieldProps: { - compressed: true, fullWidth: false, disabled: isLoading, }, @@ -99,11 +106,9 @@ export const StepAboutRule = memo( ( idAria: 'detectionEngineStepAboutRuleSeverity', 'data-test-subj': 'detectionEngineStepAboutRuleSeverity', euiFieldProps: { - compressed: true, fullWidth: false, disabled: isLoading, options: severityOptions, @@ -129,29 +133,38 @@ export const StepAboutRule = memo( euiFieldProps: { max: 100, min: 0, - compressed: true, fullWidth: false, disabled: isLoading, options: severityOptions, + showTicks: true, + tickInterval: 25, }, }} /> + ( path="threats" component={AddMitreThreat} componentProps={{ - compressed: true, idAria: 'detectionEngineStepAboutRuleMitreThreats', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepAboutRuleMitreThreats', }} /> - + + + {({ severity }) => { const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; @@ -202,7 +216,7 @@ export const StepAboutRule = memo( > - {myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE} + {RuleI18n.CONTINUE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index c72312bb908361..008a1b48610d69 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -6,7 +6,6 @@ import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash/fp'; import React from 'react'; import * as RuleI18n from '../../translations'; @@ -18,6 +17,8 @@ import { ValidationFunc, ERROR_CODE, } from '../shared_imports'; +import { isMitreAttackInvalid } from '../mitre/helpers'; +import { isUrlInvalid } from './helpers'; import * as I18n from './translations'; const { emptyField } = fieldValidators; @@ -63,7 +64,7 @@ export const schema: FormSchema = { ], }, severity: { - type: FIELD_TYPES.SELECT, + type: FIELD_TYPES.SUPER_SELECT, label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldSeverityLabel', { @@ -92,6 +93,14 @@ export const schema: FormSchema = { } ), }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + }, references: { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel', @@ -100,12 +109,34 @@ export const schema: FormSchema = { } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, + validations: [ + { + validator: ( + ...args: Parameters + ): ReturnType> | undefined => { + const [{ value, path }] = args; + let hasError = false; + (value as string[]).forEach(url => { + if (isUrlInvalid(url)) { + hasError = true; + } + }); + return hasError + ? { + code: 'ERR_FIELD_FORMAT', + path, + message: I18n.URL_FORMAT_INVALID, + } + : undefined; + }, + }, + ], }, falsePositives: { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives', + defaultMessage: 'False positives examples', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, @@ -114,7 +145,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldMitreThreatLabel', { - defaultMessage: 'MITRE ATT&CK', + defaultMessage: 'MITRE ATT&CK\\u2122', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, @@ -126,7 +157,7 @@ export const schema: FormSchema = { const [{ value, path }] = args; let hasError = false; (value as IMitreEnterpriseAttack[]).forEach(v => { - if (isEmpty(v.tactic.name) || (v.tactic.name !== 'none' && isEmpty(v.techniques))) { + if (isMitreAttackInvalid(v.tactic.name, v.techniques)) { hasError = true; } }); @@ -146,6 +177,13 @@ export const schema: FormSchema = { label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsLabel', { defaultMessage: 'Tags', }), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTagsHelpText', + { + defaultMessage: + 'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.', + } + ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index 017d4fe6fdf49e..93237697657399 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; export const ADD_REFERENCE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addReferenceDescription', { - defaultMessage: 'Add reference', + defaultMessage: 'Add reference URL', } ); export const ADD_FALSE_POSITIVE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addFalsePositiveDescription', { - defaultMessage: 'Add false positive', + defaultMessage: 'Add false positive example', } ); @@ -54,3 +54,10 @@ export const CUSTOM_MITRE_ATTACK_TECHNIQUES_REQUIRED = i18n.translate( defaultMessage: 'At least one Technique is required with a Tactic.', } ); + +export const URL_FORMAT_INVALID = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.referencesUrlInvalidError', + { + defaultMessage: 'Url is invalid format', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index cc4e959cc9c78b..ecd2ce442238fa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -4,8 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; -import { isEqual, get } from 'lodash/fp'; +import { + EuiButtonEmpty, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { isEmpty, isEqual, get } from 'lodash/fp'; import React, { memo, useCallback, useState, useEffect } from 'react'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; @@ -18,7 +24,7 @@ import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { schema } from './schema'; -import * as I18n from './translations'; +import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); @@ -34,7 +40,6 @@ const stepDefineDefaultValue = { filters: [], saved_id: null, }, - useIndicesConfig: 'true', }; const getStepDefaultValue = ( @@ -45,7 +50,6 @@ const getStepDefaultValue = ( return { ...defaultValues, isNew: false, - useIndicesConfig: `${isEqual(defaultValues.index, indicesConfig)}`, }; } else { return { @@ -66,13 +70,22 @@ export const StepDefineRule = memo( setForm, setStepData, }) => { - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(''); + const [openTimelineSearch, setOpenTimelineSearch] = useState(false); + const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); + const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( + defaultValues != null ? defaultValues.index : indicesConfig ?? [] + ); const [ - { indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - setIndices, - ] = useFetchIndexPatterns(defaultValues != null ? defaultValues.index : indicesConfig ?? []); - const [myStepData, setMyStepData] = useState(stepDefineDefaultValue); + { + browserFields, + indexPatterns: indexPatternQueryBar, + isLoading: indexPatternLoadingQueryBar, + }, + ] = useFetchIndexPatterns(mylocalIndicesConfig); + const [myStepData, setMyStepData] = useState( + getStepDefaultValue(indicesConfig, null) + ); const { form } = useForm({ defaultValue: myStepData, @@ -96,7 +109,7 @@ export const StepDefineRule = memo( const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); if (!isEqual(myDefaultValues, myStepData)) { setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(myDefaultValues.useIndicesConfig); + setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); if (!isReadOnlyView) { Object.keys(schema).forEach(key => { const val = get(key, myDefaultValues); @@ -115,6 +128,19 @@ export const StepDefineRule = memo( } }, [form]); + const handleResetIndices = useCallback(() => { + const indexField = form.getFields().index; + indexField.setValue(indicesConfig); + }, [form, indicesConfig]); + + const handleOpenTimelineSearch = useCallback(() => { + setOpenTimelineSearch(true); + }, []); + + const handleCloseTimelineSearch = useCallback(() => { + setOpenTimelineSearch(false); + }, []); + return isReadOnlyView && myStepData != null ? ( ( ) : ( <>
- + {i18n.RESET_DEFAULT_INDEX} + + ) : null, + }} componentProps={{ idAria: 'detectionEngineStepDefineRuleIndices', 'data-test-subj': 'detectionEngineStepDefineRuleIndices', euiFieldProps: { - compressed: true, fullWidth: true, isDisabled: isLoading, + placeholder: '', }, }} /> + {i18n.IMPORT_TIMELINE_QUERY} + + ), + }} component={QueryBarDefineRule} componentProps={{ - compressed: true, + browserFields, loading: indexPatternLoadingQueryBar, idAria: 'detectionEngineStepDefineRuleQueryBar', indexPattern: indexPatternQueryBar, isDisabled: isLoading, isLoading: indexPatternLoadingQueryBar, dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', + openTimelineSearch, + onCloseTimelineSearch: handleCloseTimelineSearch, resizeParentContainer, }} /> - - {({ useIndicesConfig }) => { - if (localUseIndicesConfig !== useIndicesConfig) { - const indexField = form.getFields().index; - if ( - indexField != null && - useIndicesConfig === 'true' && - !isEqual(indexField.value, indicesConfig) - ) { - indexField.setValue(indicesConfig); - setIndices(indicesConfig); - } else if ( - indexField != null && - useIndicesConfig === 'false' && - isEqual(indexField.value, indicesConfig) - ) { - indexField.setValue([]); - setIndices([]); + + {({ index }) => { + if (index != null) { + if (isEqual(index, indicesConfig) && !localUseIndicesConfig) { + setLocalUseIndicesConfig(true); + } + if (!isEqual(index, indicesConfig) && localUseIndicesConfig) { + setLocalUseIndicesConfig(false); + } + if (index != null && !isEmpty(index) && !isEqual(index, mylocalIndicesConfig)) { + setMyLocalIndicesConfig(index); } - setLocalUseIndicesConfig(useIndicesConfig); } - return null; }} @@ -208,7 +223,7 @@ export const StepDefineRule = memo( > - {myStepData.isNew ? RuleI18n.CONTINUE : RuleI18n.UPDATE} + {RuleI18n.CONTINUE} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index 9b54ada8227c61..079ec0dab4c5a6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -10,7 +10,6 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import { esKuery } from '../../../../../../../../../../src/plugins/data/public'; -import * as RuleI18n from '../../translations'; import { FieldValueQueryBar } from '../query_bar'; import { ERROR_CODE, @@ -19,33 +18,27 @@ import { FormSchema, ValidationFunc, } from '../shared_imports'; -import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY } from './translations'; +import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; const { emptyField } = fieldValidators; export const schema: FormSchema = { - useIndicesConfig: { - type: FIELD_TYPES.RADIO_GROUP, + index: { + type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepDefineRule.fieldIndicesTypeLabel', + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndexPatternsLabel', { - defaultMessage: 'Indices type', + defaultMessage: 'Index patterns', } ), - }, - index: { - type: FIELD_TYPES.COMBO_BOX, - label: i18n.translate('xpack.siem.detectionEngine.createRule.stepAboutRule.fiedIndicesLabel', { - defaultMessage: 'Indices', - }), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + helpText: {INDEX_HELPER_TEXT}, validations: [ { validator: emptyField( i18n.translate( 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', { - defaultMessage: 'An output indice name for signals is required.', + defaultMessage: 'A minimum of one index pattern is required.', } ) ), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx index 0050c59a4a2c8b..8394f090e346cd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx @@ -33,3 +33,25 @@ export const CUSTOM_INDICES = i18n.translate( defaultMessage: 'Provide custom list of indices', } ); + +export const INDEX_HELPER_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.indicesHelperDescription', + { + defaultMessage: + 'Enter the pattern of Elasticsearch indices where you would like this rule to run. By default, these will include index patterns defined in SIEM advanced settings.', + } +); + +export const RESET_DEFAULT_INDEX = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.resetDefaultIndicesButton', + { + defaultMessage: 'Reset to default index patterns', + } +); + +export const IMPORT_TIMELINE_QUERY = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.importTimelineQueryButton', + { + defaultMessage: 'Import query from saved timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 6f7e49bc8ab9a9..35b8ca6650bf6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -92,7 +92,6 @@ export const StepScheduleRule = memo( path="interval" component={ScheduleItem} componentProps={{ - compressed: true, idAria: 'detectionEngineStepScheduleRuleInterval', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleInterval', @@ -102,7 +101,6 @@ export const StepScheduleRule = memo( path="from" component={ScheduleItem} componentProps={{ - compressed: true, idAria: 'detectionEngineStepScheduleRuleFrom', isDisabled: isLoading, dataTestSubj: 'detectionEngineStepScheduleRuleFrom', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index a25ccce569dd43..ce91e15cdcf0d3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -40,14 +40,14 @@ const getTimeTypeValue = (time: string): { unit: string; value: number } => { }; const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { - const { queryBar, useIndicesConfig, isNew, ...rest } = defineStepData; + const { queryBar, isNew, ...rest } = defineStepData; const { filters, query, saved_id: savedId } = queryBar; return { ...rest, language: query.language, filters, query: query.query as string, - ...(savedId != null ? { saved_id: savedId } : {}), + ...(savedId != null && savedId !== '' ? { saved_id: savedId } : {}), }; }; @@ -72,11 +72,25 @@ const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRul }; const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { falsePositives, references, riskScore, threats, isNew, ...rest } = aboutStepData; + const { + falsePositives, + references, + riskScore, + threats, + timeline, + isNew, + ...rest + } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, + ...(timeline.id != null && timeline.title != null + ? { + timeline_id: timeline.id, + timeline_title: timeline.title, + } + : {}), threats: threats .filter(threat => threat.tactic.name !== 'none') .map(threat => ({ @@ -97,7 +111,7 @@ export const formatRule = ( scheduleData: ScheduleStepRule, ruleId?: string ): NewRule => { - const type: FormatRuleType = defineStepData.queryBar.saved_id != null ? 'saved_query' : 'query'; + const type: FormatRuleType = !isEmpty(defineStepData.queryBar.saved_id) ? 'saved_query' : 'query'; const persistData = { type, ...formatDefineStepData(defineStepData), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 3e8dbeba895461..9a0f41bbd8c517 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -12,12 +12,14 @@ import styled from 'styled-components'; import { HeaderPage } from '../../../../components/header_page'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { WrapperPage } from '../../../../components/wrapper_page'; +import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useUserInfo } from '../../components/user_info'; import { AccordionTitle } from '../components/accordion_title'; +import { FormData, FormHook } from '../components/shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; -import { usePersistRule } from '../../../../containers/detection_engine/rules'; -import { SpyRoute } from '../../../../utils/route/spy_routes'; import * as RuleI18n from '../translations'; import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types'; import { formatRule } from './helpers'; @@ -28,17 +30,50 @@ const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.schedu const ResizeEuiPanel = styled(EuiPanel)<{ height?: number; }>` + .euiAccordion__iconWrapper { + display: none; + } .euiAccordion__childWrapper { height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')}; } + .euiAccordion__button { + cursor: default !important; + &:hover { + text-decoration: none !important; + } + } +`; + +const MyEuiPanel = styled(EuiPanel)` + .euiAccordion__iconWrapper { + display: none; + } + .euiAccordion__button { + cursor: default !important; + &:hover { + text-decoration: none !important; + } + } `; export const CreateRuleComponent = React.memo(() => { + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); const [heightAccordion, setHeightAccordion] = useState(-1); - const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); + const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); + const stepsForm = useRef | null>>({ + [RuleStep.defineRule]: null, + [RuleStep.aboutRule]: null, + [RuleStep.scheduleRule]: null, + }); const stepsData = useRef>({ [RuleStep.defineRule]: { isValid: false, data: {} }, [RuleStep.aboutRule]: { isValid: false, data: {} }, @@ -50,6 +85,18 @@ export const CreateRuleComponent = React.memo(() => { [RuleStep.scheduleRule]: false, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } else if (userHasNoPermissions) { + return ; + } const setStepData = useCallback( (step: RuleStep, data: unknown, isValid: boolean) => { @@ -57,11 +104,17 @@ export const CreateRuleComponent = React.memo(() => { if (isValid) { const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item); if ([0, 1].includes(stepRuleIdx)) { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - }); - if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { + if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [step]: true, + [stepsRuleOrder[stepRuleIdx + 1]]: false, + }); + } else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [step]: true, + }); openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]); setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); } @@ -80,9 +133,13 @@ export const CreateRuleComponent = React.memo(() => { } } }, - [openAccordionId, stepsData.current, setRule] + [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] ); + const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { + stepsForm.current[step] = form; + }, []); + const getAccordionType = useCallback( (accordionId: RuleStep) => { if (accordionId === openAccordionId) { @@ -135,42 +192,38 @@ export const CreateRuleComponent = React.memo(() => { (id: RuleStep, isOpen: boolean) => { const activeRuleIdx = stepsRuleOrder.findIndex(step => step === openAccordionId); const stepRuleIdx = stepsRuleOrder.findIndex(step => step === id); - const isLatestStepsRuleValid = - stepRuleIdx === 0 - ? true - : stepsRuleOrder - .filter((stepRule, index) => index < stepRuleIdx) - .every(stepRule => stepsData.current[stepRule].isValid); - if (stepRuleIdx < activeRuleIdx && !isOpen) { + if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) { openCloseAccordion(id); } else if (stepRuleIdx >= activeRuleIdx) { if ( - openAccordionId != null && openAccordionId !== id && !stepsData.current[openAccordionId].isValid && !isStepRuleInReadOnlyView[id] && isOpen ) { openCloseAccordion(id); - } else if (!isLatestStepsRuleValid && isOpen) { - openCloseAccordion(id); - } else if (id !== openAccordionId && isOpen) { - setOpenAccordionId(id); } } }, - [isStepRuleInReadOnlyView, openAccordionId] + [isStepRuleInReadOnlyView, openAccordionId, stepsData] ); const manageIsEditable = useCallback( - (id: RuleStep) => { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [id]: false, - }); + async (id: RuleStep) => { + const activeForm = await stepsForm.current[openAccordionId]?.submit(); + if (activeForm != null && activeForm?.isValid) { + setOpenAccordionId(id); + openCloseAccordion(openAccordionId); + + setIsStepRuleInEditView({ + ...isStepRuleInReadOnlyView, + [openAccordionId]: openAccordionId === RuleStep.scheduleRule ? false : true, + [id]: false, + }); + } }, - [isStepRuleInReadOnlyView] + [isStepRuleInReadOnlyView, openAccordionId] ); if (isSaved) { @@ -183,7 +236,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -201,7 +254,7 @@ export const CreateRuleComponent = React.memo(() => { size="xs" onClick={manageIsEditable.bind(null, RuleStep.defineRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -209,14 +262,15 @@ export const CreateRuleComponent = React.memo(() => { setHeightAccordion(height)} /> - + { size="xs" onClick={manageIsEditable.bind(null, RuleStep.aboutRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -239,13 +293,14 @@ export const CreateRuleComponent = React.memo(() => { - + - + { size="xs" onClick={manageIsEditable.bind(null, RuleStep.scheduleRule)} > - {`Edit`} + {i18n.EDIT_RULE} ) } @@ -268,11 +323,12 @@ export const CreateRuleComponent = React.memo(() => { - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts index 884f3f3741228a..329bcc286fb70c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/translations.ts @@ -9,3 +9,7 @@ import { i18n } from '@kbn/i18n'; export const PAGE_TITLE = i18n.translate('xpack.siem.detectionEngine.createRule.pageTitle', { defaultMessage: 'Create new rule', }); + +export const EDIT_RULE = i18n.translate('xpack.siem.detectionEngine.createRule.editRuleButton', { + defaultMessage: 'Edit', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 1bc2bc24517e3c..679f42f0251962 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -6,10 +6,12 @@ import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { memo, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import React, { memo, useCallback, useMemo } from 'react'; +import { Redirect, useParams } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; +import { ActionCreator } from 'typescript-fsa'; +import { connect } from 'react-redux'; import { FiltersGlobal } from '../../../../components/filters_global'; import { FormattedDate } from '../../../../components/formatted_date'; import { HeaderPage } from '../../../../components/header_page'; @@ -24,210 +26,300 @@ import { } from '../../../../containers/source'; import { SpyRoute } from '../../../../utils/route/spy_routes'; -import { SignalsCharts } from '../../components/signals_chart'; +import { SignalsHistogramPanel } from '../../components/signals_histogram_panel'; import { SignalsTable } from '../../components/signals'; +import { useUserInfo } from '../../components/user_info'; import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page'; import { useSignalInfo } from '../../components/signals_info'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; import { buildSignalsRuleIdFilter } from '../../components/signals/default_config'; +import { NoWriteSignalsCallOut } from '../../components/no_write_signals_callout'; import * as detectionI18n from '../../translations'; +import { ReadOnlyCallOut } from '../components/read_only_callout'; import { RuleSwitch } from '../components/rule_switch'; import { StepPanel } from '../components/step_panel'; import { getStepsData } from '../helpers'; import * as ruleI18n from '../translations'; import * as i18n from './translations'; import { GlobalTime } from '../../../../containers/global_time'; +import { signalsHistogramOptions } from '../../components/signals_histogram_panel/config'; +import { InputsModelId } from '../../../../store/inputs/constants'; +import { esFilters } from '../../../../../../../../../src/plugins/data/common/es_query'; +import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { inputsSelectors } from '../../../../store/inputs'; +import { State } from '../../../../store'; +import { InputsRange } from '../../../../store/inputs/model'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; -interface RuleDetailsComponentProps { - signalsIndex: string | null; +interface ReduxProps { + filters: esFilters.Filter[]; + query: Query; } -export const RuleDetailsComponent = memo(({ signalsIndex }) => { - const { ruleId } = useParams(); - const [loading, rule] = useRule(ruleId); - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ - rule, - detailsView: true, - }); - const [lastSignals] = useSignalInfo({ ruleId }); - - const title = loading === true || rule === null ? : rule.name; - const subTitle = useMemo( - () => - loading === true || rule === null ? ( - - ) : ( - [ - - ), - }} - />, - rule?.updated_by != null ? ( +export interface DispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: number; + to: number; + }>; +} + +type RuleDetailsComponentProps = ReduxProps & DispatchProps; + +const RuleDetailsComponent = memo( + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + hasIndexWrite, + signalIndexName, + } = useUserInfo(); + const { ruleId } = useParams(); + const [isLoading, rule] = useRule(ruleId); + const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ + rule, + detailsView: true, + }); + const [lastSignals] = useSignalInfo({ ruleId }); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } + + const title = isLoading === true || rule === null ? : rule.name; + const subTitle = useMemo( + () => + isLoading === true || rule === null ? ( + + ) : ( + [ ), }} - /> - ) : ( - '' - ), - ] - ), - [loading, rule] - ); - - const signalDefaultFilters = useMemo( - () => (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []), - [ruleId] - ); - return ( - <> - - {({ indicesExist, indexPattern }) => { - return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( - - {({ to, from }) => ( - - - - - - - - {detectionI18n.LAST_SIGNAL} - {': '} - {lastSignals} - - ) : null, - 'Status: Comming Soon', - ]} - title={title} - > - - - - + />, + rule?.updated_by != null ? ( + + ), + }} + /> + ) : ( + '' + ), + ] + ), + [isLoading, rule] + ); - - - - - {ruleI18n.EDIT_RULE_SETTINGS} - - - - - - - - - - - - - {defineRuleData != null && ( - - )} - - - - - - {aboutRuleData != null && ( - - )} - - - - - - {scheduleRuleData != null && ( - (ruleId != null ? buildSignalsRuleIdFilter(ruleId) : []), + [ruleId] + ); + + const signalMergedFilters = useMemo(() => [...signalDefaultFilters, ...filters], [ + signalDefaultFilters, + filters, + ]); + + const updateDateRangeCallback = useCallback( + (min: number, max: number) => { + setAbsoluteRangeDatePicker({ id: 'global', from: min, to: max }); + }, + [setAbsoluteRangeDatePicker] + ); + + return ( + <> + {hasIndexWrite != null && !hasIndexWrite && } + {userHasNoPermissions && } + + {({ indicesExist, indexPattern }) => { + return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( + + {({ to, from }) => ( + + + + + + + + {detectionI18n.LAST_SIGNAL} + {': '} + {lastSignals} + + ) : null, + 'Status: Comming Soon', + ]} + title={title} + > + + + - )} - - - + - + + + + + {ruleI18n.EDIT_RULE_SETTINGS} + + + + + + - + - + + + + {defineRuleData != null && ( + + )} + + + + + + {aboutRuleData != null && ( + + )} + + - {ruleId != null && ( - + + {scheduleRuleData != null && ( + + )} + + + + + + - )} - - - )} - - ) : ( - - - - - - ); - }} - - - - - ); -}); + + + + {ruleId != null && ( + + )} + + + )} + + ) : ( + + + + + + ); + }} + + + + + ); + } +); RuleDetailsComponent.displayName = 'RuleDetailsComponent'; + +const makeMapStateToProps = () => { + const getGlobalInputs = inputsSelectors.globalSelector(); + return (state: State) => { + const globalInputs: InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + + return { + query, + filters, + }; + }; +}; + +export const RuleDetails = connect(makeMapStateToProps, { + setAbsoluteRangeDatePicker: dispatchSetAbsoluteRangeDatePicker, +})(RuleDetailsComponent); + +RuleDetails.displayName = 'RuleDetails'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 8e32f82dff0b1c..e583461f524397 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -22,6 +22,7 @@ import { WrapperPage } from '../../../../components/wrapper_page'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useUserInfo } from '../../components/user_info'; import { FormHook, FormData } from '../components/shared_imports'; import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; @@ -47,8 +48,28 @@ interface ScheduleStepRuleForm extends StepRuleForm { } export const EditRuleComponent = memo(() => { + const { + loading: initLoading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); const { ruleId } = useParams(); const [loading, rule] = useRule(ruleId); + + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } else if (userHasNoPermissions) { + return ; + } + const [initForm, setInitForm] = useState(false); const [myAboutRuleForm, setMyAboutRuleForm] = useState({ data: null, @@ -88,7 +109,7 @@ export const EditRuleComponent = memo(() => { content: ( <> - + {myDefineRuleForm.data != null && ( { content: ( <> - + {myAboutRuleForm.data != null && ( { content: ( <> - + {myScheduleRuleForm.data != null && ( { ], [ loading, + initLoading, isLoading, myAboutRuleForm, myDefineRuleForm, @@ -249,7 +271,7 @@ export const EditRuleComponent = memo(() => { }, []); if (isSaved || (rule != null && rule.immutable)) { - return ; + return ; } return ( @@ -257,7 +279,7 @@ export const EditRuleComponent = memo(() => { { responsive={false} > - + {i18n.CANCEL} - + {i18n.SAVE_CHANGES} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 46301ae808919d..cc0882dd7e426d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -5,6 +5,7 @@ */ import { pick } from 'lodash/fp'; +import { useLocation } from 'react-router-dom'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; @@ -33,7 +34,6 @@ export const getStepsData = ({ filters: rule.filters as esFilters.Filter[], saved_id: rule.saved_id ?? null, }, - useIndicesConfig: 'true', } : null; const aboutRuleData: AboutStepRule | null = @@ -45,6 +45,10 @@ export const getStepsData = ({ threats: rule.threats as IMitreEnterpriseAttack[], falsePositives: rule.false_positives, riskScore: rule.risk_score, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, } : null; const scheduleRuleData: ScheduleStepRule | null = @@ -61,3 +65,5 @@ export const getStepsData = ({ return { aboutRuleData, defineRuleData, scheduleRuleData }; }; + +export const useQuery = () => new URLSearchParams(useLocation().search); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index 8b4cc2a213589b..dd46b33ca72578 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -5,9 +5,11 @@ */ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useState } from 'react'; +import { Redirect } from 'react-router-dom'; +import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine'; import { FormattedRelativePreferenceDate } from '../../../components/formatted_date'; import { getEmptyTagValue } from '../../../components/empty_value'; import { HeaderPage } from '../../../components/header_page'; @@ -16,15 +18,34 @@ import { SpyRoute } from '../../../utils/route/spy_routes'; import { AllRules } from './all'; import { ImportRuleModal } from './components/import_rule_modal'; +import { ReadOnlyCallOut } from './components/read_only_callout'; +import { useUserInfo } from '../components/user_info'; import * as i18n from './translations'; export const RulesComponent = React.memo(() => { const [showImportModal, setShowImportModal] = useState(false); const [importCompleteToggle, setImportCompleteToggle] = useState(false); + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; const lastCompletedRun = undefined; return ( <> + {userHasNoPermissions && } setShowImportModal(false)} @@ -32,7 +53,10 @@ export const RulesComponent = React.memo(() => { /> { { setShowImportModal(true); }} @@ -59,16 +84,23 @@ export const RulesComponent = React.memo(() => { {i18n.IMPORT_RULE} - - + {i18n.ADD_NEW_RULE} - - + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index ecd6bef942bfb1..8d4407b9f73e84 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -207,15 +207,15 @@ export const COLUMN_ACTIVATE = i18n.translate( ); export const DEFINE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.defineRuleTitle', { - defaultMessage: 'Define Rule', + defaultMessage: 'Define rule', }); export const ABOUT_RULE = i18n.translate('xpack.siem.detectionEngine.rules.aboutRuleTitle', { - defaultMessage: 'About Rule', + defaultMessage: 'About rule', }); export const SCHEDULE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.scheduleRuleTitle', { - defaultMessage: 'Schedule Rule', + defaultMessage: 'Schedule rule', }); export const DEFINITION = i18n.translate('xpack.siem.detectionEngine.rules.stepDefinitionTitle', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 9b535034810bd5..541b058951be7f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -8,6 +8,7 @@ import { esFilters } from '../../../../../../../../src/plugins/data/common'; import { Rule } from '../../../containers/detection_engine/rules'; import { FieldValueQueryBar } from './components/query_bar'; import { FormData, FormHook } from './components/shared_imports'; +import { FieldValueTimeline } from './components/pick_timeline'; export interface EuiBasicTableSortTypes { field: string; @@ -76,11 +77,11 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; + timeline: FieldValueTimeline; threats: IMitreEnterpriseAttack[]; } export interface DefineStepRule extends StepRuleData { - useIndicesConfig: string; index: string[]; queryBar: FieldValueQueryBar; } @@ -108,6 +109,8 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; + timeline_id?: string; + timeline_title?: string; threats: IMitreEnterpriseAttack[]; } diff --git a/x-pack/legacy/plugins/siem/server/kibana.index.ts b/x-pack/legacy/plugins/siem/server/kibana.index.ts index 59df6432468352..7060a3f6629148 100644 --- a/x-pack/legacy/plugins/siem/server/kibana.index.ts +++ b/x-pack/legacy/plugins/siem/server/kibana.index.ts @@ -25,6 +25,8 @@ import { addPrepackedRulesRoute } from './lib/detection_engine/routes/rules/add_ import { createRulesBulkRoute } from './lib/detection_engine/routes/rules/create_rules_bulk_route'; import { updateRulesBulkRoute } from './lib/detection_engine/routes/rules/update_rules_bulk_route'; import { deleteRulesBulkRoute } from './lib/detection_engine/routes/rules/delete_rules_bulk_route'; +import { importRulesRoute } from './lib/detection_engine/routes/rules/import_rules_route'; +import { exportRulesRoute } from './lib/detection_engine/routes/rules/export_rules_route'; const APP_ID = 'siem'; @@ -50,6 +52,8 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy createRulesBulkRoute(__legacy); updateRulesBulkRoute(__legacy); deleteRulesBulkRoute(__legacy); + importRulesRoute(__legacy); + exportRulesRoute(__legacy); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals // POST /api/detection_engine/signals/status diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts new file mode 100644 index 00000000000000..bd73805600a338 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/camelcase */ +import { + NewCaseFormatted, + NewCommentFormatted, +} from '../../../../../../../x-pack/plugins/case/server'; +import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; + +// Temporary file to write mappings for case +// while Saved Object Mappings API is programmed for the NP +// See: https://github.com/elastic/kibana/issues/50309 + +export const caseSavedObjectType = 'case-workflow'; +export const caseCommentSavedObjectType = 'case-workflow-comment'; + +export const caseSavedObjectMappings: { + [caseSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseSavedObjectType]: { + properties: { + assignees: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + description: { + type: 'text', + }, + title: { + type: 'keyword', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + state: { + type: 'keyword', + }, + tags: { + type: 'keyword', + }, + case_type: { + type: 'keyword', + }, + }, + }, +}; + +export const caseCommentSavedObjectMappings: { + [caseCommentSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseCommentSavedObjectType]: { + properties: { + comment: { + type: 'text', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + full_name: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts new file mode 100644 index 00000000000000..cb358c15e5fad8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getIndexExists } from './get_index_exists'; + +class StatusCode extends Error { + status: number = -1; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +describe('get_index_exists', () => { + test('it should return a true if you have _shards', async () => { + const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 1 } }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(true); + }); + + test('it should return a false if you do NOT have _shards', async () => { + const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 0 } }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(false); + }); + + test('it should return a false if it encounters a 404', async () => { + const callWithRequest = jest.fn().mockImplementation(() => { + throw new StatusCode(404, 'I am a 404 error'); + }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(false); + }); + + test('it should reject if it encounters a non 404', async () => { + const callWithRequest = jest.fn().mockImplementation(() => { + throw new StatusCode(500, 'I am a 500 error'); + }); + await expect(getIndexExists(callWithRequest, 'some-index')).rejects.toThrow('I am a 500 error'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts index ff65caa59a866e..705f542b501245 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts @@ -4,15 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IndicesExistsParams } from 'elasticsearch'; -import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch'; import { CallWithRequest } from '../types'; export const getIndexExists = async ( - callWithRequest: CallWithRequest, + callWithRequest: CallWithRequest< + { index: string; size: number; terminate_after: number; allow_no_indices: boolean }, + {}, + { _shards: { total: number } } + >, index: string ): Promise => { - return callWithRequest('indices.exists', { - index, - }); + try { + const response = await callWithRequest('search', { + index, + size: 0, + terminate_after: 1, + allow_no_indices: true, + }); + return response._shards.total > 0; + } catch (err) { + if (err.status === 404) { + return false; + } else { + throw err; + } + } }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index edf196b96f5d02..8ed5333bd2a250 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -52,6 +52,7 @@ export const fullRuleAlertParamsRest = (): RuleAlertParamsRest => ({ created_at: '2019-12-13T16:40:33.400Z', updated_at: '2019-12-13T16:40:33.400Z', timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }); export const typicalPayload = (): Partial => ({ @@ -158,7 +159,7 @@ export const getPrivilegeRequest = (): ServerInjectOptions => ({ url: `${DETECTION_ENGINE_PRIVILEGES_URL}`, }); -interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; @@ -175,7 +176,7 @@ export const getFindResult = (): FindHit => ({ export const getFindResultWithSingleHit = (): FindHit => ({ page: 1, perPage: 1, - total: 0, + total: 1, data: [getResult()], }); @@ -271,6 +272,7 @@ export const getResult = (): RuleAlertType => ({ outputIndex: '.siem-signals', savedId: 'some-id', timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', meta: { someMeta: 'someField' }, filters: [ { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json index afe9bac9d87fec..79fb136afd52ae 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/index/signals_mapping.json @@ -36,6 +36,9 @@ "timeline_id": { "type": "keyword" }, + "timeline_title": { + "type": "keyword" + }, "max_signals": { "type": "keyword" }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 256b341fca656a..3d9719a7b248b2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -74,6 +74,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou updated_at: updatedAt, references, timeline_id: timelineId, + timeline_title: timelineTitle, version, } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); @@ -112,6 +113,7 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou outputIndex: finalIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId: ruleIdOrUuid, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 094449a5f61ac2..10dc14f7ed6106 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -28,7 +28,9 @@ describe('create_rules', () => { jest.resetAllMocks(); ({ server, alertsClient, actionsClient, elasticsearch } = createMockServer()); elasticsearch.getCluster = jest.fn().mockImplementation(() => ({ - callWithRequest: jest.fn().mockImplementation(() => true), + callWithRequest: jest + .fn() + .mockImplementation((endpoint, params) => ({ _shards: { total: 1 } })), })); createRulesRoute(server); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 476d5b8a49ba2d..cf8fb2a28288f0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -44,6 +44,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -101,6 +102,7 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = outputIndex: finalIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId: ruleId != null ? ruleId : uuid.v4(), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts index 11a076951fd8cb..bab05b065f6f73 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts @@ -24,6 +24,7 @@ import { import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { deleteRulesBulkRoute } from './delete_rules_bulk_route'; +import { BulkError } from '../utils'; describe('delete_rules', () => { let { server, alertsClient } = createMockServer(); @@ -83,10 +84,14 @@ describe('delete_rules', () => { alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); const { payload } = await server.inject(getDeleteBulkRequest()); - const parsed = JSON.parse(payload); - expect(parsed).toEqual([ - { error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' }, - ]); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); }); test('returns 404 if actionClient is not available on the route', async () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts new file mode 100644 index 00000000000000..aa17946849027c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import Hapi from 'hapi'; +import { isFunction } from 'lodash/fp'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { ExportRulesRequest } from '../../rules/types'; +import { ServerFacade } from '../../../../types'; +import { getNonPackagedRulesCount } from '../../rules/get_existing_prepackaged_rules'; +import { exportRulesSchema, exportRulesQuerySchema } from '../schemas/export_rules_schema'; +import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; +import { getExportAll } from '../../rules/get_export_all'; + +export const createExportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { + return { + method: 'POST', + path: `${DETECTION_ENGINE_RULES_URL}/_export`, + options: { + tags: ['access:siem'], + validate: { + options: { + abortEarly: false, + }, + payload: exportRulesSchema, + query: exportRulesQuerySchema, + }, + }, + async handler(request: ExportRulesRequest, headers) { + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) + ? request.getActionsClient() + : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + + const exportSizeLimit = server.config().get('savedObjects.maxImportExportSize'); + if (request.payload?.objects != null && request.payload.objects.length > exportSizeLimit) { + return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`); + } else { + const nonPackagedRulesCount = await getNonPackagedRulesCount({ alertsClient }); + if (nonPackagedRulesCount > exportSizeLimit) { + return Boom.badRequest(`Can't export more than ${exportSizeLimit} rules`); + } + } + + const exported = + request.payload?.objects != null + ? await getExportByObjectIds(alertsClient, request.payload.objects) + : await getExportAll(alertsClient); + + const response = request.query.exclude_export_details + ? headers.response(exported.rulesNdjson) + : headers.response(`${exported.rulesNdjson}${exported.exportDetails}`); + + return response + .header('Content-Disposition', `attachment; filename="${request.query.file_name}"`) + .header('Content-Type', 'application/ndjson'); + }, + }; +}; + +export const exportRulesRoute = (server: ServerFacade): void => { + server.route(createExportRulesRoute(server)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts new file mode 100644 index 00000000000000..e312b5fc6bb102 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -0,0 +1,213 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import Hapi from 'hapi'; +import { extname } from 'path'; +import { isFunction } from 'lodash/fp'; +import { createPromiseFromStreams } from '../../../../../../../../../src/legacy/utils/streams'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { createRules } from '../../rules/create_rules'; +import { ImportRulesRequest } from '../../rules/types'; +import { ServerFacade } from '../../../../types'; +import { readRules } from '../../rules/read_rules'; +import { getIndexExists } from '../../index/get_index_exists'; +import { + callWithRequestFactory, + getIndex, + createImportErrorObject, + transformImportError, + ImportSuccessError, +} from '../utils'; +import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; +import { ImportRuleAlertRest } from '../../types'; +import { transformOrImportError } from './utils'; +import { updateRules } from '../../rules/update_rules'; +import { importRulesQuerySchema, importRulesPayloadSchema } from '../schemas/import_rules_schema'; + +export const createImportRulesRoute = (server: ServerFacade): Hapi.ServerRoute => { + return { + method: 'POST', + path: `${DETECTION_ENGINE_RULES_URL}/_import`, + options: { + tags: ['access:siem'], + payload: { + maxBytes: server.config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + allow: 'multipart/form-data', + }, + validate: { + options: { + abortEarly: false, + }, + query: importRulesQuerySchema, + payload: importRulesPayloadSchema, + }, + }, + async handler(request: ImportRulesRequest, headers) { + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) + ? request.getActionsClient() + : null; + + if (!alertsClient || !actionsClient) { + return headers.response().code(404); + } + const { filename } = request.payload.file.hapi; + const fileExtension = extname(filename).toLowerCase(); + if (fileExtension !== '.ndjson') { + return Boom.badRequest(`Invalid file extension ${fileExtension}`); + } + + const objectLimit = server.config().get('savedObjects.maxImportExportSize'); + const readStream = createRulesStreamFromNdJson(request.payload.file, objectLimit); + const parsedObjects = await createPromiseFromStreams<[ImportRuleAlertRest | Error]>([ + readStream, + ]); + + const reduced = await parsedObjects.reduce>( + async (accum, parsedRule) => { + const existingImportSuccessError = await accum; + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + return createImportErrorObject({ + ruleId: '(unknown)', // TODO: Better handling where we know which ruleId is having issues with imports + statusCode: 400, + message: parsedRule.message, + existingImportSuccessError, + }); + } + + const { + description, + enabled, + false_positives: falsePositives, + from, + immutable, + query, + language, + output_index: outputIndex, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + threats, + to, + type, + references, + timeline_id: timelineId, + timeline_title: timelineTitle, + version, + } = parsedRule; + try { + const finalIndex = outputIndex != null ? outputIndex : getIndex(request, server); + const callWithRequest = callWithRequestFactory(request, server); + const indexExists = await getIndexExists(callWithRequest, finalIndex); + if (!indexExists) { + return createImportErrorObject({ + ruleId, + statusCode: 409, + message: `To create a rule, the index must exist first. Index ${finalIndex} does not exist`, + existingImportSuccessError, + }); + } + const rule = await readRules({ alertsClient, ruleId }); + if (rule == null) { + const createdRule = await createRules({ + alertsClient, + actionsClient, + createdAt: new Date().toISOString(), + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex: finalIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threats, + updatedAt: new Date().toISOString(), + references, + version, + }); + return transformOrImportError(ruleId, createdRule, existingImportSuccessError); + } else if (rule != null && request.query.overwrite) { + const updatedRule = await updateRules({ + alertsClient, + actionsClient, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + id: undefined, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threats, + references, + version, + }); + return transformOrImportError(ruleId, updatedRule, existingImportSuccessError); + } else { + return existingImportSuccessError; + } + } catch (err) { + return transformImportError(ruleId, err, existingImportSuccessError); + } + }, + Promise.resolve({ + success: true, + success_count: 0, + errors: [], + }) + ); + return reduced; + }, + }; +}; + +export const importRulesRoute = (server: ServerFacade): void => { + server.route(createImportRulesRoute(server)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts index 9ae2941e6e5f24..2410dcee203f61 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts @@ -23,6 +23,7 @@ import { } from '../__mocks__/request_responses'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; +import { BulkError } from '../utils'; describe('update_rules_bulk', () => { let { server, alertsClient, actionsClient } = createMockServer(); @@ -58,10 +59,14 @@ describe('update_rules_bulk', () => { actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); const { payload } = await server.inject(getUpdateBulkRequest()); - const parsed = JSON.parse(payload); - expect(parsed).toEqual([ - { error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' }, - ]); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); }); test('returns 404 if actionClient is not available on the route', async () => { @@ -125,10 +130,14 @@ describe('update_rules_bulk', () => { payload: [typicalPayload()], }; const { payload } = await server.inject(request); - const parsed = JSON.parse(payload); - expect(parsed).toEqual([ - { error: { message: 'rule_id: "rule-1" not found', statusCode: 404 }, id: 'rule-1' }, - ]); + const parsed: BulkError[] = JSON.parse(payload); + const expected: BulkError[] = [ + { + error: { message: 'rule_id: "rule-1" not found', status_code: 404 }, + rule_id: 'rule-1', + }, + ]; + expect(parsed).toEqual(expected); }); test('returns 200 if type is query', async () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index b30b6c791522b0..180a75bdaaeead 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -50,6 +50,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -82,6 +83,7 @@ export const createUpdateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou outputIndex, savedId, timelineId, + timelineTitle, meta, filters, id, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index ec3d9514fa5db8..6db8a8902915aa 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -38,6 +38,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { output_index: outputIndex, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -77,6 +78,7 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { outputIndex, savedId, timelineId, + timelineTitle, meta, filters, id, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index b1f61d11458fe3..c1b4c7de73f688 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -14,11 +14,15 @@ import { transformTags, getIdBulkError, transformOrBulkError, + transformRulesToNdjson, + transformAlertsToRules, + transformOrImportError, } from './utils'; import { getResult } from '../__mocks__/request_responses'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { OutputRuleAlertRest } from '../../types'; -import { BulkError } from '../utils'; +import { BulkError, ImportSuccessError } from '../utils'; +import { sampleRule } from '../../signals/__mocks__/es_results'; describe('utils', () => { describe('transformAlertToRule', () => { @@ -79,6 +83,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -141,6 +146,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -205,6 +211,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -269,6 +276,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -331,6 +339,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -396,6 +405,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -461,6 +471,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -526,6 +537,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', to: 'now', type: 'query', version: 1, @@ -642,6 +654,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual({ @@ -714,6 +727,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual(expected); @@ -746,8 +760,8 @@ describe('utils', () => { test('outputs message about id not being found if only id is defined and ruleId is undefined', () => { const error = getIdBulkError({ id: '123', ruleId: undefined }); const expected: BulkError = { - id: '123', - error: { message: 'id: "123" not found', statusCode: 404 }, + rule_id: '123', + error: { message: 'id: "123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -755,8 +769,8 @@ describe('utils', () => { test('outputs message about id not being found if only id is defined and ruleId is null', () => { const error = getIdBulkError({ id: '123', ruleId: null }); const expected: BulkError = { - id: '123', - error: { message: 'id: "123" not found', statusCode: 404 }, + rule_id: '123', + error: { message: 'id: "123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -764,8 +778,8 @@ describe('utils', () => { test('outputs message about ruleId not being found if only ruleId is defined and id is undefined', () => { const error = getIdBulkError({ id: undefined, ruleId: 'rule-id-123' }); const expected: BulkError = { - id: 'rule-id-123', - error: { message: 'rule_id: "rule-id-123" not found', statusCode: 404 }, + rule_id: 'rule-id-123', + error: { message: 'rule_id: "rule-id-123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -773,8 +787,8 @@ describe('utils', () => { test('outputs message about ruleId not being found if only ruleId is defined and id is null', () => { const error = getIdBulkError({ id: null, ruleId: 'rule-id-123' }); const expected: BulkError = { - id: 'rule-id-123', - error: { message: 'rule_id: "rule-id-123" not found', statusCode: 404 }, + rule_id: 'rule-id-123', + error: { message: 'rule_id: "rule-id-123" not found', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -782,8 +796,8 @@ describe('utils', () => { test('outputs message about both being not defined when both are undefined', () => { const error = getIdBulkError({ id: undefined, ruleId: undefined }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -791,8 +805,8 @@ describe('utils', () => { test('outputs message about both being not defined when both are null', () => { const error = getIdBulkError({ id: null, ruleId: null }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -800,8 +814,8 @@ describe('utils', () => { test('outputs message about both being not defined when id is null and ruleId is undefined', () => { const error = getIdBulkError({ id: null, ruleId: undefined }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -809,8 +823,8 @@ describe('utils', () => { test('outputs message about both being not defined when id is undefined and ruleId is null', () => { const error = getIdBulkError({ id: undefined, ruleId: null }); const expected: BulkError = { - id: '(unknown id)', - error: { message: 'id or rule_id should have been defined', statusCode: 404 }, + rule_id: '(unknown id)', + error: { message: 'id or rule_id should have been defined', status_code: 404 }, }; expect(error).toEqual(expected); }); @@ -875,6 +889,7 @@ describe('utils', () => { }, saved_id: 'some-id', timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', version: 1, }; expect(output).toEqual(expected); @@ -882,10 +897,279 @@ describe('utils', () => { test('returns 500 if the data is not of type siem alert', () => { const output = transformOrBulkError('rule-1', { data: [{ random: 1 }] }); - expect(output).toEqual({ - id: 'rule-1', - error: { message: 'Internal error transforming', statusCode: 500 }, + const expected: BulkError = { + rule_id: 'rule-1', + error: { message: 'Internal error transforming', status_code: 500 }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('transformRulesToNdjson', () => { + test('if rules are empty it returns an empty string', () => { + const ruleNdjson = transformRulesToNdjson([]); + expect(ruleNdjson).toEqual(''); + }); + + test('single rule will transform with new line ending character for ndjson', () => { + const rule = sampleRule(); + const ruleNdjson = transformRulesToNdjson([rule]); + expect(ruleNdjson.endsWith('\n')).toBe(true); + }); + + test('multiple rules will transform with two new line ending characters for ndjson', () => { + const result1 = sampleRule(); + const result2 = sampleRule(); + result2.id = 'some other id'; + result2.rule_id = 'some other id'; + result2.name = 'Some other rule'; + + const ruleNdjson = transformRulesToNdjson([result1, result2]); + // this is how we count characters in JavaScript :-) + const count = ruleNdjson.split('\n').length - 1; + expect(count).toBe(2); + }); + + test('you can parse two rules back out without errors', () => { + const result1 = sampleRule(); + const result2 = sampleRule(); + result2.id = 'some other id'; + result2.rule_id = 'some other id'; + result2.name = 'Some other rule'; + + const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleStrings = ruleNdjson.split('\n'); + const reParsed1 = JSON.parse(ruleStrings[0]); + const reParsed2 = JSON.parse(ruleStrings[1]); + expect(reParsed1).toEqual(result1); + expect(reParsed2).toEqual(result2); + }); + }); + + describe('transformAlertsToRules', () => { + test('given an empty array returns an empty array', () => { + expect(transformAlertsToRules([])).toEqual([]); + }); + + test('given single alert will return the alert transformed', () => { + const result1 = getResult(); + const transformed = transformAlertsToRules([result1]); + expect(transformed).toEqual([ + { + created_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { someMeta: 'someField' }, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + risk_score: 50, + rule_id: 'rule-1', + saved_id: 'some-id', + severity: 'high', + tags: [], + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + updated_at: '2019-12-13T16:40:33.400Z', + updated_by: 'elastic', + version: 1, + }, + ]); + }); + + test('given two alerts will return the two alerts transformed', () => { + const result1 = getResult(); + const result2 = getResult(); + result2.id = 'some other id'; + result2.params.ruleId = 'some other id'; + + const transformed = transformAlertsToRules([result1, result2]); + expect(transformed).toEqual([ + { + created_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { someMeta: 'someField' }, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + risk_score: 50, + rule_id: 'rule-1', + saved_id: 'some-id', + severity: 'high', + tags: [], + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + updated_at: '2019-12-13T16:40:33.400Z', + updated_by: 'elastic', + version: 1, + }, + { + created_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: 'some other id', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { someMeta: 'someField' }, + name: 'Detect Root/Admin Users', + output_index: '.siem-signals', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + risk_score: 50, + rule_id: 'some other id', + saved_id: 'some-id', + severity: 'high', + tags: [], + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + to: 'now', + type: 'query', + updated_at: '2019-12-13T16:40:33.400Z', + updated_by: 'elastic', + version: 1, + }, + ]); + }); + }); + + describe('transformOrImportError', () => { + test('returns 1 given success if the alert is an alert type and the existing success count is 0', () => { + const output = transformOrImportError('rule-1', getResult(), { + success: true, + success_count: 0, + errors: [], + }); + const expected: ImportSuccessError = { + success: true, + errors: [], + success_count: 1, + }; + expect(output).toEqual(expected); + }); + + test('returns 2 given successes if the alert is an alert type and the existing success count is 1', () => { + const output = transformOrImportError('rule-1', getResult(), { + success: true, + success_count: 1, + errors: [], }); + const expected: ImportSuccessError = { + success: true, + errors: [], + success_count: 2, + }; + expect(output).toEqual(expected); + }); + + test('returns 1 error and success of false if the data is not of type siem alert', () => { + const output = transformOrImportError( + 'rule-1', + { data: [{ random: 1 }] }, + { + success: true, + success_count: 1, + errors: [], + } + ); + const expected: ImportSuccessError = { + success: false, + errors: [ + { + rule_id: 'rule-1', + error: { + message: 'Internal error transforming', + status_code: 500, + }, + }, + ], + success_count: 1, + }; + expect(output).toEqual(expected); }); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index b9bf3f8a942fcb..21972905a50632 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -9,7 +9,13 @@ import { pickBy } from 'lodash/fp'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; import { RuleAlertType, isAlertType, isAlertTypes } from '../../rules/types'; import { OutputRuleAlertRest } from '../../types'; -import { createBulkErrorObject, BulkError } from '../utils'; +import { + createBulkErrorObject, + BulkError, + createSuccessObject, + ImportSuccessError, + createImportErrorObject, +} from '../utils'; export const getIdError = ({ id, @@ -85,6 +91,7 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial>): string => { + if (rules.length !== 0) { + const rulesString = rules.map(rule => JSON.stringify(rule)).join('\n'); + return `${rulesString}\n`; + } else { + return ''; + } +}; + +export const transformAlertsToRules = ( + alerts: RuleAlertType[] +): Array> => { + return alerts.map(alert => transformAlertToRule(alert)); +}; + export const transformFindAlertsOrError = (findResults: { data: unknown[] }): unknown | Boom => { if (isAlertTypes(findResults.data)) { findResults.data = findResults.data.map(alert => transformAlertToRule(alert)); @@ -127,3 +149,20 @@ export const transformOrBulkError = ( }); } }; + +export const transformOrImportError = ( + ruleId: string, + alert: unknown, + existingImportSuccessError: ImportSuccessError +): ImportSuccessError => { + if (isAlertType(alert)) { + return createSuccessObject(existingImportSuccessError); + } else { + return createImportErrorObject({ + ruleId, + statusCode: 500, + message: 'Internal error transforming', + existingImportSuccessError, + }); + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 04a72135d3f003..74eb4d6c8e918a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -1076,9 +1076,7 @@ describe('add prepackaged rules schema', () => { test('You can omit the query string when filters are present', () => { expect( - addPrepackagedRulesSchema.validate< - Partial & { meta: string }> - >({ + addPrepackagedRulesSchema.validate>({ rule_id: 'rule-1', risk_score: 50, description: 'some description', @@ -1099,7 +1097,7 @@ describe('add prepackaged rules schema', () => { ).toBeFalsy(); }); - test('validates with timeline_id', () => { + test('validates with timeline_id and timeline_title', () => { expect( addPrepackagedRulesSchema.validate>({ rule_id: 'rule-1', @@ -1117,7 +1115,131 @@ describe('add prepackaged rules schema', () => { language: 'kuery', version: 1, timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: 'timeline-id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + addPrepackagedRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + version: 1, + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts index c993b05cb5f299..49907b4a975e6d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.ts @@ -21,6 +21,7 @@ import { language, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -63,6 +64,7 @@ export const addPrepackagedRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), timeline_id, + timeline_title, meta, risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index 8dc00b66e97a3e..87916bea606493 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -1024,7 +1024,7 @@ describe('create rules schema', () => { test('You can omit the query string when filters are present', () => { expect( - createRulesSchema.validate & { meta: string }>>({ + createRulesSchema.validate>({ rule_id: 'rule-1', output_index: '.siem-signals', risk_score: 50, @@ -1045,7 +1045,7 @@ describe('create rules schema', () => { ).toBeFalsy(); }); - test('timeline_id validates', () => { + test('validates with timeline_id and timeline_title', () => { expect( createRulesSchema.validate>({ rule_id: 'rule-1', @@ -1062,8 +1062,122 @@ describe('create rules schema', () => { references: ['index-1'], query: 'some query', language: 'kuery', - timeline_id: 'some_id', + timeline_id: 'timeline-id', + timeline_title: 'timeline-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + createRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts index 614451312d04da..df5c1694d6c782 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.ts @@ -22,6 +22,7 @@ import { output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -57,6 +58,7 @@ export const createRulesSchema = Joi.object({ otherwise: Joi.forbidden(), }), timeline_id, + timeline_title, meta, risk_score: risk_score.required(), max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts new file mode 100644 index 00000000000000..7850e3a733f09b --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { exportRulesSchema, exportRulesQuerySchema } from './export_rules_schema'; +import { ExportRulesRequest } from '../../rules/types'; + +describe('create rules schema', () => { + describe('exportRulesSchema', () => { + test('null value or absent values validate', () => { + expect(exportRulesSchema.validate(null).error).toBeFalsy(); + }); + + test('empty object does not validate', () => { + expect( + exportRulesSchema.validate>({}).error + ).toBeTruthy(); + }); + + test('empty object array does validate', () => { + expect( + exportRulesSchema.validate>({ objects: [] }).error + ).toBeTruthy(); + }); + + test('array with rule_id validates', () => { + expect( + exportRulesSchema.validate>({ + objects: [{ rule_id: 'test-1' }], + }).error + ).toBeFalsy(); + }); + + test('array with id does not validate as we do not allow that on purpose since we export rule_id', () => { + expect( + exportRulesSchema.validate>({ + objects: [{ id: 'test-1' }], + }).error + ).toBeTruthy(); + }); + }); + + describe('exportRulesQuerySchema', () => { + test('default value for file_name is export.ndjson', () => { + expect( + exportRulesQuerySchema.validate>({}).value.file_name + ).toEqual('export.ndjson'); + }); + + test('default value for exclude_export_details is false', () => { + expect( + exportRulesQuerySchema.validate>({}).value + .exclude_export_details + ).toEqual(false); + }); + + test('file_name validates', () => { + expect( + exportRulesQuerySchema.validate>({ + file_name: 'test.ndjson', + }).error + ).toBeFalsy(); + }); + + test('file_name does not validate with a number', () => { + expect( + exportRulesQuerySchema.validate< + Partial & { file_name: number }> + >({ + file_name: 5, + }).error + ).toBeTruthy(); + }); + + test('exclude_export_details validates with a boolean true', () => { + expect( + exportRulesQuerySchema.validate>({ + exclude_export_details: true, + }).error + ).toBeFalsy(); + }); + + test('exclude_export_details does not validate with a weird string', () => { + expect( + exportRulesQuerySchema.validate< + Partial< + Omit & { + exclude_export_details: string; + } + > + >({ + exclude_export_details: 'blah', + }).error + ).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.ts new file mode 100644 index 00000000000000..a14d81604d9f8e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/export_rules_schema.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { objects, exclude_export_details, file_name } from './schemas'; +/* eslint-disable @typescript-eslint/camelcase */ + +export const exportRulesSchema = Joi.object({ + objects, +}) + .min(1) + .allow(null); + +export const exportRulesQuerySchema = Joi.object({ + file_name: file_name.default('export.ndjson'), + exclude_export_details: exclude_export_details.default(false), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts new file mode 100644 index 00000000000000..09bc7a70711ec4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -0,0 +1,1327 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + importRulesSchema, + importRulesQuerySchema, + importRulesPayloadSchema, +} from './import_rules_schema'; +import { ThreatParams, RuleAlertParamsRest, ImportRuleAlertRest } from '../../types'; +import { ImportRulesRequest } from '../../rules/types'; + +describe('import rules schema', () => { + describe('importRulesSchema', () => { + test('empty objects do not validate', () => { + expect(importRulesSchema.validate>({}).error).toBeTruthy(); + }); + + test('made up values do not validate', () => { + expect( + importRulesSchema.validate>({ + madeUp: 'hi', + }).error + ).toBeTruthy(); + }); + + test('[rule_id] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + type: 'query', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type, interval] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type, interval, index] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + type: 'query', + interval: '5m', + index: ['index-1'], + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, name, severity, type, query, index, interval] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + name: 'some-name', + severity: 'severity', + type: 'query', + query: 'some query', + index: ['index-1'], + interval: '5m', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language] does not validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeTruthy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language, risk_score] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, query, language, risk_score, output_index] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + risk_score: 50, + }).error + ).toBeFalsy(); + }); + + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, output_index] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + }).error + ).toBeFalsy(); + }); + test('You can send in an empty array to threats', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [], + }).error + ).toBeFalsy(); + }); + test('[rule_id, description, from, to, index, name, severity, interval, type, filter, risk_score, output_index, threats] does validate', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + threats: [ + { + framework: 'someFramework', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + techniques: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error + ).toBeFalsy(); + }); + + test('allows references to be sent as valid', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('defaults references to an array', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + }).value.references + ).toEqual([]); + }); + + test('references cannot be numbers', () => { + expect( + importRulesSchema.validate< + Partial> & { references: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + references: [5], + }).error + ).toBeTruthy(); + }); + + test('indexes cannot be numbers', () => { + expect( + importRulesSchema.validate< + Partial> & { index: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: [5], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + query: 'some-query', + language: 'kuery', + }).error + ).toBeTruthy(); + }); + + test('defaults interval to 5 min', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + type: 'query', + }).value.interval + ).toEqual('5m'); + }); + + test('defaults max signals to 100', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + }).value.max_signals + ).toEqual(100); + }); + + test('saved_id is required when type is saved_query and will not validate without out', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + }).error + ).toBeTruthy(); + }); + + test('saved_id is required when type is saved_query and validates with it', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + output_index: '.siem-signals', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + }).error + ).toBeFalsy(); + }); + + test('saved_query type can have filters with it', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + filters: [], + }).error + ).toBeFalsy(); + }); + + test('filters cannot be a string', () => { + expect( + importRulesSchema.validate< + Partial & { filters: string }> + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + filters: 'some string', + }).error + ).toBeTruthy(); + }); + + test('language validates with kuery', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeFalsy(); + }); + + test('language validates with lucene', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + risk_score: 50, + output_index: '.siem-signals', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'lucene', + }).error + ).toBeFalsy(); + }); + + test('language does not validate with something made up', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'something-made-up', + }).error + ).toBeTruthy(); + }); + + test('max_signals cannot be negative', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: -1, + }).error + ).toBeTruthy(); + }); + + test('max_signals cannot be zero', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 0, + }).error + ).toBeTruthy(); + }); + + test('max_signals can be 1', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can optionally send in an array of tags', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + tags: ['tag_1', 'tag_2'], + }).error + ).toBeFalsy(); + }); + + test('You cannot send in an array of tags that are numbers', () => { + expect( + importRulesSchema.validate> & { tags: number[] }>( + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + tags: [0, 1, 2], + } + ).error + ).toBeTruthy(); + }); + + test('You cannot send in an array of threats that are missing "framework"', () => { + expect( + importRulesSchema.validate< + Partial> & { + threats: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [ + { + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + techniques: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error + ).toBeTruthy(); + }); + test('You cannot send in an array of threats that are missing "tactic"', () => { + expect( + importRulesSchema.validate< + Partial> & { + threats: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [ + { + framework: 'fake', + techniques: [ + { + id: 'techniqueId', + name: 'techniqueName', + reference: 'techniqueRef', + }, + ], + }, + ], + }).error + ).toBeTruthy(); + }); + test('You cannot send in an array of threats that are missing "techniques"', () => { + expect( + importRulesSchema.validate< + Partial> & { + threats: Array>>; + } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + threats: [ + { + framework: 'fake', + tactic: { + id: 'fakeId', + name: 'fakeName', + reference: 'fakeRef', + }, + }, + ], + }).error + ).toBeTruthy(); + }); + + test('You can optionally send in an array of false positives', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + false_positives: ['false_1', 'false_2'], + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You cannot send in an array of false positives that are numbers', () => { + expect( + importRulesSchema.validate< + Partial> & { false_positives: number[] } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + false_positives: [5, 4], + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You can optionally set the immutable to be true', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You cannot set the immutable to be a number', () => { + expect( + importRulesSchema.validate< + Partial> & { immutable: number } + >({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: 5, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You cannot set the risk_score to 101', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 101, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You cannot set the risk_score to -1', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: -1, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeTruthy(); + }); + + test('You can set the risk_score to 0', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 0, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can set the risk_score to 100', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 100, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('You can set meta to any object you want', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + meta: { + somethingMadeUp: { somethingElse: true }, + }, + }).error + ).toBeFalsy(); + }); + + test('You cannot create meta as a string', () => { + expect( + importRulesSchema.validate & { meta: string }>>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + max_signals: 1, + meta: 'should not work', + }).error + ).toBeTruthy(); + }); + + test('You can omit the query string when filters are present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + immutable: true, + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + language: 'kuery', + filters: [], + max_signals: 1, + }).error + ).toBeFalsy(); + }); + + test('validates with timeline_id and timeline_title', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'timeline-id', + timeline_title: 'timeline-title', + }).error + ).toBeFalsy(); + }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: 'some_id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('rule_id is required and you cannot get by with just id', () => { + expect( + importRulesSchema.validate>({ + id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + }).error + ).toBeTruthy(); + }); + + test('it validates with created_at, updated_at, created_by, updated_by values', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + created_at: '2020-01-09T06:15:24.749Z', + updated_at: '2020-01-09T06:15:24.749Z', + created_by: 'Braden Hassanabad', + updated_by: 'Evan Hassanabad', + }).error + ).toBeFalsy(); + }); + + test('it does not validate with epoch strings for created_at', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + created_at: '1578550728650', + updated_at: '2020-01-09T06:15:24.749Z', + created_by: 'Braden Hassanabad', + updated_by: 'Evan Hassanabad', + }).error + ).toBeTruthy(); + }); + + test('it does not validate with epoch strings for updated_at', () => { + expect( + importRulesSchema.validate>({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + references: ['index-1'], + query: 'some query', + language: 'kuery', + created_at: '2020-01-09T06:15:24.749Z', + updated_at: '1578550728650', + created_by: 'Braden Hassanabad', + updated_by: 'Evan Hassanabad', + }).error + ).toBeTruthy(); + }); + }); + + describe('importRulesQuerySchema', () => { + test('overwrite gets a default value of false', () => { + expect( + importRulesQuerySchema.validate>({}).value.overwrite + ).toEqual(false); + }); + + test('overwrite validates with a boolean true', () => { + expect( + importRulesQuerySchema.validate>({ + overwrite: true, + }).error + ).toBeFalsy(); + }); + + test('overwrite does not validate with a weird string', () => { + expect( + importRulesQuerySchema.validate< + Partial< + Omit & { + overwrite: string; + } + > + >({ + overwrite: 'blah', + }).error + ).toBeTruthy(); + }); + }); + + describe('importRulesPayloadSchema', () => { + test('does not validate with an empty object', () => { + expect(importRulesPayloadSchema.validate({}).error).toBeTruthy(); + }); + + test('does validate with a file object', () => { + expect(importRulesPayloadSchema.validate({ file: {} }).error).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts new file mode 100644 index 00000000000000..df825c442fff65 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { + id, + created_at, + updated_at, + created_by, + updated_by, + enabled, + description, + false_positives, + filters, + from, + immutable, + index, + rule_id, + interval, + query, + language, + output_index, + saved_id, + timeline_id, + timeline_title, + meta, + risk_score, + max_signals, + name, + severity, + tags, + to, + type, + threats, + references, + version, +} from './schemas'; +/* eslint-enable @typescript-eslint/camelcase */ + +import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants'; + +/** + * Differences from this and the createRulesSchema are + * - rule_id is required + * - id is optional (but ignored in the import code - rule_id is exclusively used for imports) + * - created_at is optional (but ignored in the import code) + * - updated_at is optional (but ignored in the import code) + * - created_by is optional (but ignored in the import code) + * - updated_by is optional (but ignored in the import code) + */ +export const importRulesSchema = Joi.object({ + id, + description: description.required(), + enabled: enabled.default(true), + false_positives: false_positives.default([]), + filters, + from: from.required(), + rule_id: rule_id.required(), + immutable: immutable.default(false), + index, + interval: interval.default('5m'), + query: query.allow('').default(''), + language: language.default('kuery'), + output_index, + saved_id: saved_id.when('type', { + is: 'saved_query', + then: Joi.required(), + otherwise: Joi.forbidden(), + }), + timeline_id, + timeline_title, + meta, + risk_score: risk_score.required(), + max_signals: max_signals.default(DEFAULT_MAX_SIGNALS), + name: name.required(), + severity: severity.required(), + tags: tags.default([]), + to: to.required(), + type: type.required(), + threats: threats.default([]), + references: references.default([]), + version: version.default(1), + created_at, + updated_at, + created_by, + updated_by, +}); + +export const importRulesQuerySchema = Joi.object({ + overwrite: Joi.boolean().default(false), +}); + +export const importRulesPayloadSchema = Joi.object({ + file: Joi.object().required(), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts index 68d3166c74d6da..ecca661d2b8565 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/schemas.ts @@ -9,7 +9,9 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ export const description = Joi.string(); export const enabled = Joi.boolean(); +export const exclude_export_details = Joi.boolean(); export const false_positives = Joi.array().items(Joi.string()); +export const file_name = Joi.string(); export const filters = Joi.array(); export const from = Joi.string(); export const immutable = Joi.boolean(); @@ -21,9 +23,19 @@ export const index = Joi.array() export const interval = Joi.string(); export const query = Joi.string(); export const language = Joi.string().valid('kuery', 'lucene'); +export const objects = Joi.array().items( + Joi.object({ + rule_id, + }).required() +); export const output_index = Joi.string(); export const saved_id = Joi.string(); export const timeline_id = Joi.string(); +export const timeline_title = Joi.string().when('timeline_id', { + is: Joi.exist(), + then: Joi.required(), + otherwise: Joi.forbidden(), +}); export const meta = Joi.object(); export const max_signals = Joi.number().greater(0); export const name = Joi.string(); @@ -70,7 +82,6 @@ export const threat_technique = Joi.object({ reference: threat_technique_reference.required(), }); export const threat_techniques = Joi.array().items(threat_technique.required()); - export const threats = Joi.array().items( Joi.object({ framework: threat_framework.required(), @@ -78,5 +89,12 @@ export const threats = Joi.array().items( techniques: threat_techniques.required(), }) ); - +export const created_at = Joi.string() + .isoDate() + .strict(); +export const updated_at = Joi.string() + .isoDate() + .strict(); +export const created_by = Joi.string(); +export const updated_by = Joi.string(); export const version = Joi.number().min(1); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index 1f00e0a13866a4..f713840ab43f93 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -867,7 +867,7 @@ describe('update rules schema', () => { ).toBeTruthy(); }); - test('timeline_id validates', () => { + test('validates with timeline_id and timeline_title', () => { expect( updateRulesSchema.validate>({ id: 'rule-1', @@ -881,7 +881,101 @@ describe('update rules schema', () => { type: 'saved_query', saved_id: 'some id', timeline_id: 'some-id', + timeline_title: 'some-title', }).error ).toBeFalsy(); }); + + test('You cannot omit timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + }).error + ).toBeTruthy(); + }); + + test('You cannot have a null value for timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'timeline-id', + timeline_title: null, + }).error + ).toBeTruthy(); + }); + + test('You cannot have empty string for timeline_title when timeline_id is present', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: 'some-id', + timeline_title: '', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title with an empty timeline_id', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_id: '', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); + + test('You cannot have timeline_title without timeline_id', () => { + expect( + updateRulesSchema.validate>({ + id: 'rule-1', + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'saved_query', + saved_id: 'some id', + timeline_title: 'some-title', + }).error + ).toBeTruthy(); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts index afd8a5fce48331..9c3188738faea7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.ts @@ -22,6 +22,7 @@ import { output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, @@ -53,6 +54,7 @@ export const updateRulesSchema = Joi.object({ output_index, saved_id, timeline_id, + timeline_title, meta, risk_score, max_signals, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index fa95c77f646d61..ffd0c791c5bb60 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -6,7 +6,15 @@ import Boom from 'boom'; -import { transformError, transformBulkError, BulkError } from './utils'; +import { + transformError, + transformBulkError, + BulkError, + createSuccessObject, + ImportSuccessError, + createImportErrorObject, + transformImportError, +} from './utils'; describe('utils', () => { describe('transformError', () => { @@ -63,8 +71,8 @@ describe('utils', () => { const boom = new Boom('some boom message', { statusCode: 400 }); const transformed = transformBulkError('rule-1', boom); const expected: BulkError = { - id: 'rule-1', - error: { message: 'some boom message', statusCode: 400 }, + rule_id: 'rule-1', + error: { message: 'some boom message', status_code: 400 }, }; expect(transformed).toEqual(expected); }); @@ -77,8 +85,8 @@ describe('utils', () => { }; const transformed = transformBulkError('rule-1', error); const expected: BulkError = { - id: 'rule-1', - error: { message: 'some message', statusCode: 403 }, + rule_id: 'rule-1', + error: { message: 'some message', status_code: 403 }, }; expect(transformed).toEqual(expected); }); @@ -90,8 +98,8 @@ describe('utils', () => { }; const transformed = transformBulkError('rule-1', error); const expected: BulkError = { - id: 'rule-1', - error: { message: 'some message', statusCode: 500 }, + rule_id: 'rule-1', + error: { message: 'some message', status_code: 500 }, }; expect(transformed).toEqual(expected); }); @@ -100,8 +108,168 @@ describe('utils', () => { const error: TypeError = new TypeError('I have a type error'); const transformed = transformBulkError('rule-1', error); const expected: BulkError = { - id: 'rule-1', - error: { message: 'I have a type error', statusCode: 400 }, + rule_id: 'rule-1', + error: { message: 'I have a type error', status_code: 400 }, + }; + expect(transformed).toEqual(expected); + }); + }); + + describe('createSuccessObject', () => { + test('it should increment the existing success object by 1', () => { + const success = createSuccessObject({ + success_count: 0, + success: true, + errors: [], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: true, + errors: [], + }; + expect(success).toEqual(expected); + }); + + test('it should increment the existing success object by 1 and not touch the boolean or errors', () => { + const success = createSuccessObject({ + success_count: 0, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + ], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + ], + }; + expect(success).toEqual(expected); + }); + }); + + describe('createImportErrorObject', () => { + test('it creates an error message and does not increment the success count', () => { + const error = createImportErrorObject({ + ruleId: 'some-rule-id', + statusCode: 400, + message: 'some-message', + existingImportSuccessError: { + success_count: 1, + success: true, + errors: [], + }, + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }; + expect(error).toEqual(expected); + }); + + test('appends a second error message and does not increment the success count', () => { + const error = createImportErrorObject({ + ruleId: 'some-rule-id', + statusCode: 400, + message: 'some-message', + existingImportSuccessError: { + success_count: 1, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + ], + }, + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'rule-1', error: { status_code: 500, message: 'some sad sad sad error' } }, + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + ], + }; + expect(error).toEqual(expected); + }); + }); + + describe('transformImportError', () => { + test('returns transformed object if it is a boom object', () => { + const boom = new Boom('some boom message', { statusCode: 400 }); + const transformed = transformImportError('rule-1', boom, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 400, message: 'some boom message' } }, + ], + }; + expect(transformed).toEqual(expected); + }); + + test('returns a normal error if it is some non boom object that has a statusCode', () => { + const error: Error & { statusCode?: number } = { + statusCode: 403, + name: 'some name', + message: 'some message', + }; + const transformed = transformImportError('rule-1', error, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 403, message: 'some message' } }, + ], + }; + expect(transformed).toEqual(expected); + }); + + test('returns a 500 if the status code is not set', () => { + const error: Error & { statusCode?: number } = { + name: 'some name', + message: 'some message', + }; + const transformed = transformImportError('rule-1', error, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 500, message: 'some message' } }, + ], + }; + expect(transformed).toEqual(expected); + }); + + test('it detects a TypeError and returns a Boom status of 400', () => { + const error: TypeError = new TypeError('I have a type error'); + const transformed = transformImportError('rule-1', error, { + success_count: 1, + success: false, + errors: [{ rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }], + }); + const expected: ImportSuccessError = { + success_count: 1, + success: false, + errors: [ + { rule_id: 'some-rule-id', error: { status_code: 400, message: 'some-message' } }, + { rule_id: 'rule-1', error: { status_code: 400, message: 'I have a type error' } }, + ], }; expect(transformed).toEqual(expected); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index d9a8efd6738837..19cd972b60e1a8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -27,12 +27,13 @@ export const transformError = (err: Error & { statusCode?: number }) => { }; export interface BulkError { - id: string; + rule_id: string; error: { - statusCode: number; + status_code: number; message: string; }; } + export const createBulkErrorObject = ({ ruleId, statusCode, @@ -43,14 +44,84 @@ export const createBulkErrorObject = ({ message: string; }): BulkError => { return { - id: ruleId, + rule_id: ruleId, error: { - statusCode, + status_code: statusCode, message, }, }; }; +export interface ImportSuccessError { + success: boolean; + success_count: number; + errors: BulkError[]; +} + +export const createSuccessObject = ( + existingImportSuccessError: ImportSuccessError +): ImportSuccessError => { + return { + success_count: existingImportSuccessError.success_count + 1, + success: existingImportSuccessError.success, + errors: existingImportSuccessError.errors, + }; +}; + +export const createImportErrorObject = ({ + ruleId, + statusCode, + message, + existingImportSuccessError, +}: { + ruleId: string; + statusCode: number; + message: string; + existingImportSuccessError: ImportSuccessError; +}): ImportSuccessError => { + return { + success: false, + errors: [ + ...existingImportSuccessError.errors, + createBulkErrorObject({ + ruleId, + statusCode, + message, + }), + ], + success_count: existingImportSuccessError.success_count, + }; +}; + +export const transformImportError = ( + ruleId: string, + err: Error & { statusCode?: number }, + existingImportSuccessError: ImportSuccessError +): ImportSuccessError => { + if (Boom.isBoom(err)) { + return createImportErrorObject({ + ruleId, + statusCode: err.output.statusCode, + message: err.message, + existingImportSuccessError, + }); + } else if (err instanceof TypeError) { + return createImportErrorObject({ + ruleId, + statusCode: 400, + message: err.message, + existingImportSuccessError, + }); + } else { + return createImportErrorObject({ + ruleId, + statusCode: err.statusCode ?? 500, + message: err.message, + existingImportSuccessError, + }); + } +}; + export const transformBulkError = ( ruleId: string, err: Error & { statusCode?: number } @@ -76,13 +147,19 @@ export const transformBulkError = ( } }; -export const getIndex = (request: RequestFacade, server: ServerFacade): string => { +export const getIndex = ( + request: RequestFacade | Omit, + server: ServerFacade +): string => { const spaceId = server.plugins.spaces.getSpaceId(request); const signalsIndex = server.config().get(`xpack.${APP_ID}.${SIGNALS_INDEX_KEY}`); return `${signalsIndex}-${spaceId}`; }; -export const callWithRequestFactory = (request: RequestFacade, server: ServerFacade) => { +export const callWithRequestFactory = ( + request: RequestFacade | Omit, + server: ServerFacade +) => { const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); return (endpoint: string, params: T, options?: U) => { return callWithRequest(request, endpoint, params, options); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index 07cf0b0c716cc0..d2f76907d7aa3e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -19,6 +19,7 @@ export const createRules = async ({ language, savedId, timelineId, + timelineTitle, meta, filters, ruleId, @@ -56,6 +57,7 @@ export const createRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, maxSignals, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts new file mode 100644 index 00000000000000..fce3c90ef18e79 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Readable } from 'stream'; +import { createRulesStreamFromNdJson } from './create_rules_stream_from_ndjson'; +import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; +import { ImportRuleAlertRest } from '../types'; + +const readStreamToCompletion = (stream: Readable) => { + return createPromiseFromStreams([stream, createConcatStream([])]); +}; + +export const getOutputSample = (): Partial => ({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', +}); + +export const getSampleAsNdjson = (sample: Partial): string => { + return `${JSON.stringify(sample)}\n`; +}; + +describe('create_rules_stream_from_ndjson', () => { + describe('createRulesStreamFromNdJson', () => { + test('transforms an ndjson stream into a stream of rule objects', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + expect(result).toEqual([ + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + { + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + ]); + }); + + test('skips empty lines', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push('\n'); + this.push(getSampleAsNdjson(sample2)); + this.push(''); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + expect(result).toEqual([ + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + { + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + ]); + }); + + test('filters the export details entry from the stream', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(getSampleAsNdjson(sample2)); + this.push('{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n'); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + expect(result).toEqual([ + { + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + { + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }, + ]); + }); + + test('handles non parsable JSON strings and inserts the error as part of the return array', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push('{,,,,\n'); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + const resultOrError = result as Error[]; + expect(resultOrError[0]).toEqual({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + expect(resultOrError[1].message).toEqual('Unexpected token , in JSON at position 1'); + expect(resultOrError[2]).toEqual({ + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + }); + + test('handles non-validated data', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(`{}\n`); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + const resultOrError = result as TypeError[]; + expect(resultOrError[0]).toEqual({ + rule_id: 'rule-1', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + expect(resultOrError[1].message).toEqual( + 'child "description" fails because ["description" is required]' + ); + expect(resultOrError[2]).toEqual({ + rule_id: 'rule-2', + output_index: '.siem-signals', + risk_score: 50, + description: 'some description', + from: 'now-5m', + to: 'now', + index: ['index-1'], + name: 'some-name', + severity: 'severity', + interval: '5m', + type: 'query', + enabled: true, + false_positives: [], + immutable: false, + query: '', + language: 'kuery', + max_signals: 100, + tags: [], + threats: [], + references: [], + version: 1, + }); + }); + + test('non validated data is an instanceof TypeError', async () => { + const sample1 = getOutputSample(); + const sample2 = getOutputSample(); + sample2.rule_id = 'rule-2'; + const ndJsonStream = new Readable({ + read() { + this.push(getSampleAsNdjson(sample1)); + this.push(`{}\n`); + this.push(getSampleAsNdjson(sample2)); + this.push(null); + }, + }); + const rulesObjectsStream = createRulesStreamFromNdJson(ndJsonStream, 1000); + const result = await readStreamToCompletion(rulesObjectsStream); + const resultOrError = result as TypeError[]; + expect(resultOrError[1] instanceof TypeError).toEqual(true); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts new file mode 100644 index 00000000000000..6d58171a3245dc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Readable, Transform } from 'stream'; +import { has, isString } from 'lodash/fp'; +import { ImportRuleAlertRest } from '../types'; +import { + createSplitStream, + createMapStream, + createFilterStream, + createConcatStream, +} from '../../../../../../../../src/legacy/utils/streams'; +import { importRulesSchema } from '../routes/schemas/import_rules_schema'; + +export interface RulesObjectsExportResultDetails { + /** number of successfully exported objects */ + exportedCount: number; +} + +export const parseNdjsonStrings = (): Transform => { + return createMapStream((ndJsonStr: string) => { + if (isString(ndJsonStr) && ndJsonStr.trim() !== '') { + try { + return JSON.parse(ndJsonStr); + } catch (err) { + return err; + } + } + }); +}; + +export const filterExportedCounts = (): Transform => { + return createFilterStream( + obj => obj != null && !has('exported_count', obj) + ); +}; + +export const validateRules = (): Transform => { + return createMapStream((obj: ImportRuleAlertRest) => { + if (!(obj instanceof Error)) { + const validated = importRulesSchema.validate(obj); + if (validated.error != null) { + return new TypeError(validated.error.message); + } else { + return validated.value; + } + } else { + return obj; + } + }); +}; + +// Adaptation from: saved_objects/import/create_limit_stream.ts +export const createLimitStream = (limit: number): Transform => { + let counter = 0; + return new Transform({ + objectMode: true, + async transform(obj, _, done) { + if (counter >= limit) { + return done(new Error(`Can't import more than ${limit} rules`)); + } + counter++; + done(undefined, obj); + }, + }); +}; + +// TODO: Capture both the line number and the rule_id if you have that information for the error message +// eventually and then pass it down so we can give error messages on the line number + +/** + * Inspiration and the pattern of code followed is from: + * saved_objects/lib/create_saved_objects_stream_from_ndjson.ts + */ +export const createRulesStreamFromNdJson = ( + ndJsonStream: Readable, + ruleLimit: number +): Transform => { + return ndJsonStream + .pipe(createSplitStream('\n')) + .pipe(parseNdjsonStrings()) + .pipe(filterExportedCounts()) + .pipe(validateRules()) + .pipe(createLimitStream(ruleLimit)) + .pipe(createConcatStream([])); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts index 5f69082e3fc719..e193e123f42817 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/find_rules.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { FindResult } from '../../../../../alerting/server/alerts_client'; import { SIGNALS_ID } from '../../../../common/constants'; -import { FindRuleParams, RuleAlertType } from './types'; +import { FindRuleParams } from './types'; export const getFilter = (filter: string | null | undefined) => { if (filter == null) { @@ -23,7 +24,7 @@ export const findRules = async ({ filter, sortField, sortOrder, -}: FindRuleParams) => { +}: FindRuleParams): Promise => { return alertsClient.find({ options: { fields, @@ -33,10 +34,5 @@ export const findRules = async ({ sortOrder, sortField, }, - }) as Promise<{ - page: number; - perPage: number; - total: number; - data: RuleAlertType[]; - }>; + }); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts index bb28a5575f51ee..dc308263baab61 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.test.ts @@ -11,7 +11,13 @@ import { getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../routes/__mocks__/request_responses'; -import { getExistingPrepackagedRules } from './get_existing_prepackaged_rules'; +import { + getExistingPrepackagedRules, + getNonPackagedRules, + getRules, + getRulesCount, + getNonPackagedRulesCount, +} from './get_existing_prepackaged_rules'; describe('get_existing_prepackaged_rules', () => { afterEach(() => { @@ -33,9 +39,11 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); const result1 = getResult(); + result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result2 = getResult(); + result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; alertsClient.find.mockResolvedValueOnce( @@ -56,12 +64,15 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); const result1 = getResult(); + result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result2 = getResult(); + result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result3 = getResult(); + result3.params.immutable = true; result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; alertsClient.find.mockResolvedValueOnce( @@ -87,12 +98,15 @@ describe('get_existing_prepackaged_rules', () => { const alertsClient = alertsClientMock.create(); const result1 = getResult(); + result1.params.immutable = true; result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result2 = getResult(); + result2.params.immutable = true; result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; const result3 = getResult(); + result3.params.immutable = true; result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; alertsClient.find.mockResolvedValueOnce( @@ -111,4 +125,221 @@ describe('get_existing_prepackaged_rules', () => { expect(rules).toEqual([result1, result2, result3]); }); }); + + describe('getNonPackagedRules', () => { + test('should return a single item in a single page', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([getResult()]); + }); + + test('should return 2 items over two pages, one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 2 }) + ); + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 2 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([result1, result2]); + }); + + test('should return 3 items with over 3 pages one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result3], perPage: 1, page: 2, total: 3 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([result1, result2, result3]); + }); + + test('should return 3 items over 1 pages with all on one page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ + data: [result1, result2, result3], + perPage: 3, + page: 1, + total: 3, + }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRules({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual([result1, result2, result3]); + }); + }); + + describe('getRules', () => { + test('should return a single item in a single page', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([getResult()]); + }); + + test('should return 2 items over two pages, one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 2 }) + ); + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 2 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([result1, result2]); + }); + + test('should return 3 items with over 3 pages one per page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result1], perPage: 1, page: 1, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result2], perPage: 1, page: 2, total: 3 }) + ); + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ data: [result3], perPage: 1, page: 2, total: 3 }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([result1, result2, result3]); + }); + + test('should return 3 items over 1 pages with all on one page', async () => { + const alertsClient = alertsClientMock.create(); + + const result1 = getResult(); + result1.id = '4baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result2 = getResult(); + result2.id = '5baa53f8-96da-44ee-ad58-41bccb7f9f3d'; + + const result3 = getResult(); + result3.id = 'f3e1bf0b-b95f-43da-b1de-5d2f0af2287a'; + + alertsClient.find.mockResolvedValueOnce( + getFindResultWithMultiHits({ + data: [result1, result2, result3], + perPage: 3, + page: 1, + total: 3, + }) + ); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRules({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual([result1, result2, result3]); + }); + }); + + describe('getRulesCount', () => { + test('it returns a count', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getRulesCount({ + alertsClient: unsafeCast, + filter: '', + }); + expect(rules).toEqual(1); + }); + }); + + describe('getNonPackagedRulesCount', () => { + test('it returns a count', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const rules = await getNonPackagedRulesCount({ + alertsClient: unsafeCast, + }); + expect(rules).toEqual(1); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts index fa2e2124d05390..b7ab6a97634a80 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_existing_prepackaged_rules.ts @@ -9,18 +9,46 @@ import { AlertsClient } from '../../../../../alerting'; import { RuleAlertType, isAlertTypes } from './types'; import { findRules } from './find_rules'; -export const DEFAULT_PER_PAGE: number = 100; +export const DEFAULT_PER_PAGE = 100; +export const FILTER_NON_PREPACKED_RULES = `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`; +export const FILTER_PREPACKED_RULES = `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`; -export const getExistingPrepackagedRules = async ({ +export const getNonPackagedRulesCount = async ({ + alertsClient, +}: { + alertsClient: AlertsClient; +}): Promise => { + return getRulesCount({ alertsClient, filter: FILTER_NON_PREPACKED_RULES }); +}; + +export const getRulesCount = async ({ + alertsClient, + filter, +}: { + alertsClient: AlertsClient; + filter: string; +}): Promise => { + const firstRule = await findRules({ + alertsClient, + filter, + perPage: 1, + page: 1, + }); + return firstRule.total; +}; + +export const getRules = async ({ alertsClient, perPage = DEFAULT_PER_PAGE, + filter, }: { alertsClient: AlertsClient; perPage?: number; + filter: string; }): Promise => { const firstPrepackedRules = await findRules({ alertsClient, - filter: `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`, + filter, perPage, page: 1, }); @@ -40,7 +68,7 @@ export const getExistingPrepackagedRules = async ({ // page index starts at 2 as we already got the first page and we have more pages to go return findRules({ alertsClient, - filter: `alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`, + filter, perPage, page: page + 2, }); @@ -58,3 +86,31 @@ export const getExistingPrepackagedRules = async ({ } } }; + +export const getNonPackagedRules = async ({ + alertsClient, + perPage = DEFAULT_PER_PAGE, +}: { + alertsClient: AlertsClient; + perPage?: number; +}): Promise => { + return getRules({ + alertsClient, + perPage, + filter: FILTER_NON_PREPACKED_RULES, + }); +}; + +export const getExistingPrepackagedRules = async ({ + alertsClient, + perPage = DEFAULT_PER_PAGE, +}: { + alertsClient: AlertsClient; + perPage?: number; +}): Promise => { + return getRules({ + alertsClient, + perPage, + filter: FILTER_PREPACKED_RULES, + }); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.ts new file mode 100644 index 00000000000000..eb9756af8fde10 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../alerting/server/alerts_client.mock'; +import { + getResult, + getFindResultWithSingleHit, + FindHit, +} from '../routes/__mocks__/request_responses'; +import { AlertsClient } from '../../../../../alerting'; +import { getExportAll } from './get_export_all'; + +describe('getExportAll', () => { + test('it exports everything from the alerts client', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const exports = await getExportAll(unsafeCast); + expect(exports).toEqual({ + rulesNdjson: + '{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"saved_id":"some-id","timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threats":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"techniques":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"version":1}\n', + exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n', + }); + }); + + test('it will export empty rules', async () => { + const alertsClient = alertsClientMock.create(); + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const exports = await getExportAll(unsafeCast); + expect(exports).toEqual({ + rulesNdjson: '', + exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n', + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts new file mode 100644 index 00000000000000..dca6eba4e65567 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertsClient } from '../../../../../alerting'; +import { getNonPackagedRules } from './get_existing_prepackaged_rules'; +import { getExportDetailsNdjson } from './get_export_details_ndjson'; +import { transformAlertsToRules, transformRulesToNdjson } from '../routes/rules/utils'; + +export const getExportAll = async ( + alertsClient: AlertsClient +): Promise<{ + rulesNdjson: string; + exportDetails: string; +}> => { + const ruleAlertTypes = await getNonPackagedRules({ alertsClient }); + const rules = transformAlertsToRules(ruleAlertTypes); + const rulesNdjson = transformRulesToNdjson(rules); + const exportDetails = getExportDetailsNdjson(rules); + return { rulesNdjson, exportDetails }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts new file mode 100644 index 00000000000000..a861d80a66fd53 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../alerting/server/alerts_client.mock'; +import { getExportByObjectIds, getRulesFromObjects, RulesErrors } from './get_export_by_object_ids'; +import { + getResult, + getFindResultWithSingleHit, + FindHit, +} from '../routes/__mocks__/request_responses'; +import { AlertsClient } from '../../../../../alerting'; + +describe('get_export_by_object_ids', () => { + describe('getExportByObjectIds', () => { + test('it exports object ids into an expected string with new line characters', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getExportByObjectIds(unsafeCast, objects); + expect(exports).toEqual({ + rulesNdjson: + '{"created_at":"2019-12-13T16:40:33.400Z","updated_at":"2019-12-13T16:40:33.400Z","created_by":"elastic","description":"Detecting root and admin users","enabled":true,"false_positives":[],"filters":[{"query":{"match_phrase":{"host.name":"some-host"}}}],"from":"now-6m","id":"04128c15-0d1b-4716-a4c5-46997ac7f3bd","immutable":false,"index":["auditbeat-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"rule-1","language":"kuery","output_index":".siem-signals","max_signals":100,"risk_score":50,"name":"Detect Root/Admin Users","query":"user.name: root or user.name: admin","references":["http://www.example.com","https://ww.example.com"],"saved_id":"some-id","timeline_id":"some-timeline-id","timeline_title":"some-timeline-title","meta":{"someMeta":"someField"},"severity":"high","updated_by":"elastic","tags":[],"to":"now","type":"query","threats":[{"framework":"MITRE ATT&CK","tactic":{"id":"TA0040","name":"impact","reference":"https://attack.mitre.org/tactics/TA0040/"},"techniques":[{"id":"T1499","name":"endpoint denial of service","reference":"https://attack.mitre.org/techniques/T1499/"}]}],"version":1}\n', + exportDetails: '{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n', + }); + }); + + test('it does not export immutable rules', async () => { + const alertsClient = alertsClientMock.create(); + const result = getResult(); + result.params.immutable = true; + + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [result], + }; + + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getExportByObjectIds(unsafeCast, objects); + expect(exports).toEqual({ + rulesNdjson: '', + exportDetails: + '{"exported_count":0,"missing_rules":[{"rule_id":"rule-1"}],"missing_rules_count":1}\n', + }); + }); + }); + + describe('getRulesFromObjects', () => { + test('it returns transformed rules from objects sent in', async () => { + const alertsClient = alertsClientMock.create(); + alertsClient.get.mockResolvedValue(getResult()); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getRulesFromObjects(unsafeCast, objects); + const expected: RulesErrors = { + missingRules: [], + rules: [ + { + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + output_index: '.siem-signals', + max_signals: 100, + risk_score: 50, + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://www.example.com', 'https://ww.example.com'], + saved_id: 'some-id', + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threats: [ + { + framework: 'MITRE ATT&CK', + tactic: { + id: 'TA0040', + name: 'impact', + reference: 'https://attack.mitre.org/tactics/TA0040/', + }, + techniques: [ + { + id: 'T1499', + name: 'endpoint denial of service', + reference: 'https://attack.mitre.org/techniques/T1499/', + }, + ], + }, + ], + version: 1, + }, + ], + }; + expect(exports).toEqual(expected); + }); + + test('it does not transform the rule if the rule is an immutable rule and designates it as a missing rule', async () => { + const alertsClient = alertsClientMock.create(); + const result = getResult(); + result.params.immutable = true; + + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [result], + }; + + alertsClient.get.mockResolvedValue(result); + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getRulesFromObjects(unsafeCast, objects); + const expected: RulesErrors = { + missingRules: [{ rule_id: 'rule-1' }], + rules: [], + }; + expect(exports).toEqual(expected); + }); + + test('it exports missing rules', async () => { + const alertsClient = alertsClientMock.create(); + + const findResult: FindHit = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + + alertsClient.get.mockRejectedValue({ output: { statusCode: 404 } }); + alertsClient.find.mockResolvedValue(findResult); + + const unsafeCast: AlertsClient = (alertsClient as unknown) as AlertsClient; + const objects = [{ rule_id: 'rule-1' }]; + const exports = await getRulesFromObjects(unsafeCast, objects); + const expected: RulesErrors = { + missingRules: [{ rule_id: 'rule-1' }], + rules: [], + }; + expect(exports).toEqual(expected); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts new file mode 100644 index 00000000000000..a5cf1bbfb78588 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertsClient } from '../../../../../alerting'; +import { getExportDetailsNdjson } from './get_export_details_ndjson'; +import { isAlertType } from '../rules/types'; +import { readRules } from './read_rules'; +import { transformRulesToNdjson, transformAlertToRule } from '../routes/rules/utils'; +import { OutputRuleAlertRest } from '../types'; + +export interface RulesErrors { + missingRules: Array<{ rule_id: string }>; + rules: Array>; +} + +export const getExportByObjectIds = async ( + alertsClient: AlertsClient, + objects: Array<{ rule_id: string }> +): Promise<{ + rulesNdjson: string; + exportDetails: string; +}> => { + const rulesAndErrors = await getRulesFromObjects(alertsClient, objects); + const rulesNdjson = transformRulesToNdjson(rulesAndErrors.rules); + const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); + return { rulesNdjson, exportDetails }; +}; + +export const getRulesFromObjects = async ( + alertsClient: AlertsClient, + objects: Array<{ rule_id: string }> +): Promise => { + const alertsAndErrors = await objects.reduce>( + async (accumPromise, object) => { + const accum = await accumPromise; + const rule = await readRules({ alertsClient, ruleId: object.rule_id }); + if (rule != null && isAlertType(rule) && rule.params.immutable !== true) { + const transformedRule = transformAlertToRule(rule); + return { + missingRules: accum.missingRules, + rules: [...accum.rules, transformedRule], + }; + } else { + return { + missingRules: [...accum.missingRules, { rule_id: object.rule_id }], + rules: accum.rules, + }; + } + }, + Promise.resolve({ + exportedCount: 0, + missingRules: [], + rules: [], + }) + ); + return alertsAndErrors; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts new file mode 100644 index 00000000000000..431b3776fd9e2a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sampleRule } from '../signals/__mocks__/es_results'; +import { getExportDetailsNdjson } from './get_export_details_ndjson'; + +describe('getExportDetailsNdjson', () => { + test('it ends with a new line character', () => { + const rule = sampleRule(); + const details = getExportDetailsNdjson([rule]); + expect(details.endsWith('\n')).toEqual(true); + }); + + test('it exports a correct count given a single rule and no missing rules', () => { + const rule = sampleRule(); + const details = getExportDetailsNdjson([rule]); + const reParsed = JSON.parse(details); + expect(reParsed).toEqual({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, + }); + }); + + test('it exports a correct count given a no rules and a single missing rule', () => { + const missingRule = { rule_id: 'rule-1' }; + const details = getExportDetailsNdjson([], [missingRule]); + const reParsed = JSON.parse(details); + expect(reParsed).toEqual({ + exported_count: 0, + missing_rules: [{ rule_id: 'rule-1' }], + missing_rules_count: 1, + }); + }); + + test('it exports a correct count given multiple rules and multiple missing rules', () => { + const rule1 = sampleRule(); + const rule2 = sampleRule(); + rule2.rule_id = 'some other id'; + rule2.id = 'some other id'; + + const missingRule1 = { rule_id: 'rule-1' }; + const missingRule2 = { rule_id: 'rule-2' }; + + const details = getExportDetailsNdjson([rule1, rule2], [missingRule1, missingRule2]); + const reParsed = JSON.parse(details); + expect(reParsed).toEqual({ + exported_count: 2, + missing_rules: [missingRule1, missingRule2], + missing_rules_count: 2, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.ts new file mode 100644 index 00000000000000..a39541d044bc36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_details_ndjson.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OutputRuleAlertRest } from '../types'; + +export const getExportDetailsNdjson = ( + rules: Array>, + missingRules: Array<{ rule_id: string }> = [] +): string => { + const stringified = JSON.stringify({ + exported_count: rules.length, + missing_rules: missingRules, + missing_rules_count: missingRules.length, + }); + return `${stringified}\n`; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts index 9acfbf8c43221c..9c3be64f71a0dc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -26,6 +26,7 @@ export const installPrepackagedRules = async ( language, saved_id: savedId, timeline_id: timelineId, + timeline_title: timelineTitle, meta, filters, rule_id: ruleId, @@ -55,6 +56,7 @@ export const installPrepackagedRules = async ( outputIndex, savedId, timelineId, + timelineTitle, meta, filters, ruleId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 4e370bfdc5bc9f..49b3c5d6802b48 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -283,6 +283,7 @@ import rule273 from './splunk_detect_use_of_cmdexe_to_launch_script_interpreters import rule274 from './splunk_child_processes_of_spoolsvexe.json'; import rule275 from './splunk_detect_psexec_with_accepteula_flag.json'; import rule276 from './splunk_processes_created_by_netsh.json'; +import rule277 from './process_execution_via_wmi.json'; export const rawRules = [ rule1, @@ -561,4 +562,5 @@ export const rawRules = [ rule274, rule275, rule276, + rule277, ]; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json index dee04ee4fea8a5..00976ea21cd44b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json @@ -9,7 +9,7 @@ "type": "query", "from": "now-6m", "to": "now", - "query": "(destination.port:6665 or destination.port:6666 or destination.port:6667 or destination.port:6668 or destination.port:6669) and not destination.ip:10.0.0.0/8 and not destination.ip:172.16.0.0/12 and not destination.ip:192.168.0.0/16", + "query": "(destination.port:20 or destination.port:21) and not destination.ip:10.0.0.0/8 and not destination.ip:172.16.0.0/12 and not destination.ip:192.168.0.0/16", "language": "kuery", "filters": [], "enabled": false, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json index c5a16bfef72486..69383d91ccbb90 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json @@ -9,7 +9,7 @@ "type": "query", "from": "now-6m", "to": "now", - "query": "destination.port:3389 and not source.ip:10.0.0.0/8 and not source.ip:172.16.0.0/12 and not source.ip:192.168.0.0/16", + "query": "(destination.port:8080 or destination.port:3128) and not destination.ip:10.0.0.0/8 and not destination.ip:172.16.0.0/12 and not destination.ip:192.168.0.0/16", "language": "kuery", "filters": [], "enabled": false, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json new file mode 100644 index 00000000000000..d6743c1ead4acb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/process_execution_via_wmi.json @@ -0,0 +1,17 @@ +{ + "rule_id": "14ba7cd9-1489-459b-99a4-153c7a3f9abb", + "risk_score": 50, + "description": "Process Execution via WMI", + "immutable": true, + "interval": "5m", + "name": "Process Execution via WMI", + "severity": "low", + "type": "query", + "from": "now-6m", + "to": "now", + "query": "process.name:scrcons.exe", + "language": "kuery", + "filters": [], + "enabled": false, + "version": 1 +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts index 9c83ae924486d4..e8fa0b562bd24e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Alert } from '../../../../../alerting/server/types'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; import { findRules } from './find_rules'; -import { RuleAlertType, ReadRuleParams, isAlertType } from './types'; +import { ReadRuleParams, isAlertType } from './types'; /** * This reads the rules through a cascade try of what is fastest to what is slowest. @@ -20,7 +21,7 @@ export const readRules = async ({ alertsClient, id, ruleId, -}: ReadRuleParams): Promise => { +}: ReadRuleParams): Promise => { if (id != null) { try { const rule = await alertsClient.get({ id }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index 4f4c0da7127cd7..4d9073e4b38d95 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -5,6 +5,7 @@ */ import { get } from 'lodash/fp'; +import { Readable } from 'stream'; import { SIGNALS_ID } from '../../../../common/constants'; import { AlertsClient } from '../../../../../alerting/server/alerts_client'; @@ -47,6 +48,24 @@ export interface BulkRulesRequest extends RequestFacade { payload: RuleAlertParamsRest[]; } +export interface HapiReadableStream extends Readable { + hapi: { + filename: string; + }; +} +export interface ImportRulesRequest extends Omit { + query: { overwrite: boolean }; + payload: { file: HapiReadableStream }; +} + +export interface ExportRulesRequest extends Omit { + payload: { objects: Array<{ rule_id: string }> | null | undefined }; + query: { + file_name: string; + exclude_export_details: boolean; + }; +} + export type QueryRequest = Omit & { query: { id: string | undefined; rule_id: string | undefined }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index c9dac82b6eb8f7..0fe4b15437af85 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -74,6 +74,7 @@ export const updateRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, from, @@ -118,6 +119,7 @@ export const updateRules = async ({ outputIndex, savedId, timelineId, + timelineTitle, meta, filters, index, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh new file mode 100755 index 00000000000000..b46b5a0e806392 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +EXCLUDE_DETAILS=${1:-false} + +# Note: This file does not use jq on purpose for testing and pipe redirections + +# Example get all the rules except pre-packaged rules +# ./export_rules.sh + +# Example get the export details at the end +# ./export_rules.sh false + +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh new file mode 100755 index 00000000000000..bed9753b1b6f93 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + + +# Uses a default if no argument is specified +RULES=${1:-./rules/export/ruleid_queries.json} +EXCLUDE_DETAILS=${2:-false} + +# Note: This file does not use jq on purpose for testing and pipe redirections + +# Example get all the rules except pre-packaged rules +# ./export_rules_by_rule_id.sh + +# Example get the export details at the end +# ./export_rules_by_rule_id.sh ./rules/export/ruleid_queries.json false +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS} \ + -d @${RULES} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh new file mode 100755 index 00000000000000..614024996cb39a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_by_rule_id_to_file.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a defaults if no arguments are specified +RULES=${1:-./rules/export/ruleid_queries.json} +FILENAME=${2:-test.ndjson} +EXCLUDE_DETAILS=${3:-false} + +# Example export to the file named test.ndjson +# ./export_rules_by_rule_id_to_file.sh + +# Example export to the file named test.ndjson with export details appended +# ./export_rules_by_rule_id_to_file.sh ./rules/export/ruleid_queries.json test.ndjson false +curl -s -k -OJ \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}&file_name=${FILENAME}" \ + -d @${RULES} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh new file mode 100755 index 00000000000000..a45e8036d004ea --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/export_rules_to_file.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +FILENAME=${1:-test.ndjson} +EXCLUDE_DETAILS=${2:-false} + +# Example export to the file named test.ndjson +# ./export_rules_to_file.sh + +# Example export to the file named test.ndjson with export details appended +# ./export_rules_to_file.sh test.ndjson false +curl -s -k -OJ \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_export?exclude_export_details=${EXCLUDE_DETAILS}&file_name=${FILENAME}" diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh new file mode 100755 index 00000000000000..5bb9ec589a7e3d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a defaults if no argument is specified +RULES=${1:-./rules/import/multiple_ruleid_queries.ndjson} +OVERWRITE=${2:-true} + +# Example to import and overwrite everything from ./rules/import/multiple_ruleid_queries.ndjson +# ./import_rules.sh + +# Example to not overwrite everything if it exists from ./rules/import/multiple_ruleid_queries.ndjson +# ./import_rules.sh ./rules/import/multiple_ruleid_queries.ndjson false +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_import?overwrite=${OVERWRITE}" \ + --form file=@${RULES} \ + | jq .; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh new file mode 100755 index 00000000000000..dfc58bb5c1ab87 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/import_rules_no_overwrite.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +RULES=${1:-./rules/import/multiple_ruleid_queries.ndjson} + +# Example: ./import_rules_no_overwrite.sh +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_import \ + --form file=@${RULES} \ + | jq .; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json new file mode 100644 index 00000000000000..fabc37d9f5766e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/export/ruleid_queries.json @@ -0,0 +1,10 @@ +{ + "objects": [ + { + "rule_id": "query-rule-id-1" + }, + { + "rule_id": "query-rule-id-2" + } + ] +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson new file mode 100644 index 00000000000000..a9de8b1e475a36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/import/multiple_ruleid_queries.ndjson @@ -0,0 +1,3 @@ +{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"exported_count":2,"missing_rules":[],"missing_rules_count":0} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json index 2f995029447ffe..eb87a14e0c6885 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_timelineid.json @@ -7,5 +7,6 @@ "from": "now-6m", "to": "now", "query": "user.name: root or user.name: admin", - "timeline_id": "timeline-id" + "timeline_id": "timeline-id", + "timeline_title": "timeline_title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json index 60095a0a6a8334..e9d955f920571b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/queries/query_with_everything.json @@ -21,7 +21,7 @@ } ], "enabled": false, - "immutable": true, + "immutable": false, "index": ["auditbeat-*", "filebeat-*"], "interval": "5m", "query": "user.name: root or user.name: admin", @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "timeline_id": "timeline_id", + "timeline_title": "timeline_title", "version": 1 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json index 2628b69eb064d7..16d5d6cc2b36a2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/saved_queries/saved_query_with_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "saved_id": "test-saved-id", - "timeline_id": "test-timeline-id" + "timeline_id": "test-timeline-id", + "timeline_title": "test-timeline-title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson new file mode 100644 index 00000000000000..94fc36ef6f7bfa --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/multiple_ruleid_queries_corrupted.ndjson @@ -0,0 +1,4 @@ +{"created_at":"2020-01-09T01:38:00.740Z","updated_at":"2020-01-09T01:38:00.740Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"6688f367-1aa2-4895-a5a8-b3701eecf57d","immutable":false,"interval":"5m","rule_id":"query-rule-id-1","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":1,"name":"Query with a rule id Number 1","query":"user.name: root or user.name: admin","references":[],"severity":"high","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1}, +{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-2","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"created_at":"2020-01-09T01:38:00.745Z","updated_at":"2020-01-09T01:38:00.745Z","created_by":"elastic_kibana","description":"Query with a rule_id that acts like an external id","enabled":true,"false_positives":[],"from":"now-6m","id":"7a912444-6cfa-4c8f-83f4-2b26fb2a2ed9","immutable":false,"interval":"5m","rule_id":"query-rule-id-3","language":"kuery","output_index":".siem-signals-frank-hassanabad-default","max_signals":100,"risk_score":2,"name":"Query with a rule id Number 2","query":"user.name: root or user.name: admin","references":[],"severity":"low","updated_by":"elastic_kibana","tags":[],"to":"now","type":"query","threats":[],"version":1} +{"exported_count":2,"missing_rules":[],"missing_rules_count":0} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json index 4da285e5b09bf9..7fc8de9fe8f9ec 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_query_everything.json @@ -78,5 +78,6 @@ "Some plain text string here explaining why this is a valid thing to look out for" ], "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title", "version": 42 } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json index 8cfa3303f54a62..27dee7dd81463c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/updates/update_timelineid.json @@ -1,4 +1,5 @@ { "rule_id": "query-rule-id", - "timeline_id": "other-timeline-id" + "timeline_id": "other-timeline-id", + "timeline_title": "other-timeline-title" } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts index 5e50b65b517172..ede82a597b238e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -31,6 +31,7 @@ export const sampleRuleAlertParams = ( filters: undefined, savedId: undefined, timelineId: undefined, + timelineTitle: undefined, meta: undefined, threats: undefined, version: 1, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index 0a3526d32e511a..1093ff3a8a462d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -34,6 +34,7 @@ export const buildRule = ({ false_positives: ruleParams.falsePositives, saved_id: ruleParams.savedId, timeline_id: ruleParams.timelineId, + timeline_title: ruleParams.timelineTitle, meta: ruleParams.meta, max_signals: ruleParams.maxSignals, risk_score: ruleParams.riskScore, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 87d31abbc5371a..ab2c1733b04ca2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -42,6 +42,7 @@ export const signalRulesAlertType = ({ outputIndex: schema.nullable(schema.string()), savedId: schema.nullable(schema.string()), timelineId: schema.nullable(schema.string()), + timelineTitle: schema.nullable(schema.string()), meta: schema.nullable(schema.object({}, { allowUnknowns: true })), query: schema.nullable(schema.string()), filters: schema.nullable(schema.arrayOf(schema.object({}, { allowUnknowns: true }))), diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index f4a8263da6ba42..968c7d9cb1cf04 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -44,6 +44,7 @@ export interface RuleAlertParams { tags: string[]; to: string; timelineId: string | undefined | null; + timelineTitle: string | undefined | null; threats: ThreatParams[] | undefined | null; type: 'query' | 'saved_query'; version: number; @@ -60,6 +61,7 @@ export type RuleAlertParamsRest = Omit< | 'savedId' | 'riskScore' | 'timelineId' + | 'timelineTitle' | 'outputIndex' | 'updatedAt' | 'createdAt' @@ -68,6 +70,7 @@ export type RuleAlertParamsRest = Omit< false_positives: RuleAlertParams['falsePositives']; saved_id: RuleAlertParams['savedId']; timeline_id: RuleAlertParams['timelineId']; + timeline_title: RuleAlertParams['timelineTitle']; max_signals: RuleAlertParams['maxSignals']; risk_score: RuleAlertParams['riskScore']; output_index: RuleAlertParams['outputIndex']; @@ -81,4 +84,9 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { updated_by: string | undefined | null; }; +export type ImportRuleAlertRest = Omit & { + id: string | undefined | null; + rule_id: string; +}; + export type CallWithRequest = (endpoint: string, params: T, options?: U) => Promise; diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 8a47aa2a27082b..90ae79ef19d5ba 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -60,7 +60,7 @@ export class Plugin { ], read: ['config'], }, - ui: ['show'], + ui: ['show', 'crud'], }, read: { api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts index 79a4eeb6dc48b3..777471e209adc4 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts @@ -99,7 +99,7 @@ export const setup = async (): Promise => { const tabs = ['snapshots', 'repositories']; testBed - .find('tab') + .find(`${tab}_tab`) .at(tabs.indexOf(tab)) .simulate('click'); }; @@ -360,7 +360,10 @@ export type TestSubjects = | 'state' | 'state.title' | 'state.value' - | 'tab' + | 'repositories_tab' + | 'snapshots_tab' + | 'policies_tab' + | 'restore_status_tab' | 'tableHeaderCell_durationInMillis_3' | 'tableHeaderCell_durationInMillis_3.tableHeaderSortButton' | 'tableHeaderCell_indices_4' diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index d9f2c1b510a145..cb2e94df756091 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -95,6 +95,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setCleanupRepositoryResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 503 : 200; + + server.respondWith('POST', `${API_BASE_PATH}repositories/:name/cleanup`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + const setGetPolicyResponse = (response?: HttpResponse) => { server.respondWith('GET', `${API_BASE_PATH}policy/:name`, [ 200, @@ -113,6 +123,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setLoadIndicesResponse, setAddPolicyResponse, setGetPolicyResponse, + setCleanupRepositoryResponse, }; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index aa659441043ae4..517c7a0059a7e5 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -88,8 +88,15 @@ describe('', () => { test('should have 4 tabs', () => { const { find } = testBed; - expect(find('tab').length).toBe(4); - expect(find('tab').map(t => t.text())).toEqual([ + const tabs = [ + find('snapshots_tab'), + find('repositories_tab'), + find('policies_tab'), + find('restore_status_tab'), + ]; + + expect(tabs.length).toBe(4); + expect(tabs.map(t => t.text())).toEqual([ 'Snapshots', 'Repositories', 'Policies', diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts index 5900d53afa0b4f..b9b26c55903246 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts @@ -157,3 +157,15 @@ export interface InvalidRepositoryVerification { } export type RepositoryVerification = ValidRepositoryVerification | InvalidRepositoryVerification; + +export interface SuccessfulRepositoryCleanup { + cleaned: true; + response: object; +} + +export interface FailedRepositoryCleanup { + cleaned: false; + error: object; +} + +export type RepositoryCleanup = FailedRepositoryCleanup | SuccessfulRepositoryCleanup; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index 844394deb4f8d9..481516479df4e9 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -103,6 +103,7 @@ export const UIM_REPOSITORY_DELETE = 'repository_delete'; export const UIM_REPOSITORY_DELETE_MANY = 'repository_delete_many'; export const UIM_REPOSITORY_SHOW_DETAILS_CLICK = 'repository_show_details_click'; export const UIM_REPOSITORY_DETAIL_PANEL_VERIFY = 'repository_detail_panel_verify'; +export const UIM_REPOSITORY_DETAIL_PANEL_CLEANUP = 'repository_detail_panel_cleanup'; export const UIM_SNAPSHOT_LIST_LOAD = 'snapshot_list_load'; export const UIM_SNAPSHOT_SHOW_DETAILS_CLICK = 'snapshot_show_details_click'; export const UIM_SNAPSHOT_DETAIL_PANEL_SUMMARY_TAB = 'snapshot_detail_panel_summary_tab'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx index 35d5c0b610b3cd..f89aa869b33667 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx @@ -150,7 +150,7 @@ export const SnapshotRestoreHome: React.FunctionComponent onSectionChange(tab.id)} isSelected={tab.id === section} key={tab.id} - data-test-subj="tab" + data-test-subj={tab.id.toLowerCase() + '_tab'} > {tab.name} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx index c03162bae8bd2d..0a3fcfc2ec6e7a 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -19,6 +18,8 @@ import { EuiLink, EuiSpacer, EuiTitle, + EuiCodeBlock, + EuiText, } from '@elastic/eui'; import 'brace/theme/textmate'; @@ -28,12 +29,17 @@ import { documentationLinksService } from '../../../../services/documentation'; import { useLoadRepository, verifyRepository as verifyRepositoryRequest, + cleanupRepository as cleanupRepositoryRequest, } from '../../../../services/http'; import { textService } from '../../../../services/text'; import { linkToSnapshots, linkToEditRepository } from '../../../../services/navigation'; import { REPOSITORY_TYPES } from '../../../../../../common/constants'; -import { Repository, RepositoryVerification } from '../../../../../../common/types'; +import { + Repository, + RepositoryVerification, + RepositoryCleanup, +} from '../../../../../../common/types'; import { RepositoryDeleteProvider, SectionError, @@ -61,7 +67,9 @@ export const RepositoryDetails: React.FunctionComponent = ({ const { FormattedMessage } = i18n; const { error, data: repositoryDetails } = useLoadRepository(repositoryName); const [verification, setVerification] = useState(undefined); + const [cleanup, setCleanup] = useState(undefined); const [isLoadingVerification, setIsLoadingVerification] = useState(false); + const [isLoadingCleanup, setIsLoadingCleanup] = useState(false); const verifyRepository = async () => { setIsLoadingVerification(true); @@ -70,11 +78,20 @@ export const RepositoryDetails: React.FunctionComponent = ({ setIsLoadingVerification(false); }; - // Reset verification state when repository name changes, either from adjust URL or clicking + const cleanupRepository = async () => { + setIsLoadingCleanup(true); + const { data } = await cleanupRepositoryRequest(repositoryName); + setCleanup(data.cleanup); + setIsLoadingCleanup(false); + }; + + // Reset verification state and cleanup when repository name changes, either from adjust URL or clicking // into a different repository in table list. useEffect(() => { setVerification(undefined); setIsLoadingVerification(false); + setCleanup(undefined); + setIsLoadingCleanup(false); }, [repositoryName]); const renderBody = () => { @@ -231,6 +248,8 @@ export const RepositoryDetails: React.FunctionComponent = ({ {renderVerification()} + + {renderCleanup()} ); }; @@ -260,36 +279,13 @@ export const RepositoryDetails: React.FunctionComponent = ({ {verification ? ( - + {JSON.stringify( verification.valid ? verification.response : verification.error, null, 2 )} - setOptions={{ - showLineNumbers: false, - tabSize: 2, - maxLines: Infinity, - }} - editorProps={{ - $blockScrolling: Infinity, - }} - showGutter={false} - minLines={6} - aria-label={ - - } - /> + ) : null} @@ -318,6 +314,78 @@ export const RepositoryDetails: React.FunctionComponent = ({ ); + const renderCleanup = () => ( + <> + +

+ +

+
+ + +

+ +

+
+ {cleanup ? ( + <> + + {cleanup.cleaned ? ( +
+ +

+ +

+
+ + {JSON.stringify(cleanup.response, null, 2)} + +
+ ) : ( + +

+ {cleanup.error + ? JSON.stringify(cleanup.error) + : i18n.translate('xpack.snapshotRestore.repositoryDetails.cleanupUnknownError', { + defaultMessage: '503: Unknown error', + })} +

+
+ )} + + ) : null} + + + + + + ); + const renderFooter = () => { return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx index 4b5270b44d593c..1df06f67c35b12 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx @@ -96,6 +96,7 @@ export const RepositoryTable: React.FunctionComponent = ({ }, }, { + field: 'actions', name: i18n.translate('xpack.snapshotRestore.repositoryList.table.actionsColumnTitle', { defaultMessage: 'Actions', }), @@ -302,8 +303,8 @@ export const RepositoryTable: React.FunctionComponent = ({ rowProps={() => ({ 'data-test-subj': 'row', })} - cellProps={() => ({ - 'data-test-subj': 'cell', + cellProps={(item, field) => ({ + 'data-test-subj': `${field.name}_cell`, })} data-test-subj="repositoryTable" /> diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts index 171e949ccee754..b92f21ea6a9b67 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts @@ -11,6 +11,7 @@ import { UIM_REPOSITORY_DELETE, UIM_REPOSITORY_DELETE_MANY, UIM_REPOSITORY_DETAIL_PANEL_VERIFY, + UIM_REPOSITORY_DETAIL_PANEL_CLEANUP, } from '../../constants'; import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; @@ -44,6 +45,20 @@ export const verifyRepository = async (name: Repository['name']) => { return result; }; +export const cleanupRepository = async (name: Repository['name']) => { + const result = await sendRequest({ + path: httpService.addBasePath( + `${API_BASE_PATH}repositories/${encodeURIComponent(name)}/cleanup` + ), + method: 'post', + body: undefined, + }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_REPOSITORY_DETAIL_PANEL_CLEANUP); + return result; +}; + export const useLoadRepositoryTypes = () => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}repository_types`), diff --git a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts similarity index 73% rename from x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts rename to x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts index 82fe30aaa7d2eb..794bf99c3d918b 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts @@ -7,10 +7,10 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) => { const ca = components.clientAction.factory; - Client.prototype.slm = components.clientAction.namespaceFactory(); - const slm = Client.prototype.slm.prototype; + Client.prototype.sr = components.clientAction.namespaceFactory(); + const sr = Client.prototype.sr.prototype; - slm.policies = ca({ + sr.policies = ca({ urls: [ { fmt: '/_slm/policy', @@ -19,7 +19,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); - slm.policy = ca({ + sr.policy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -33,7 +33,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); - slm.deletePolicy = ca({ + sr.deletePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -47,7 +47,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'DELETE', }); - slm.executePolicy = ca({ + sr.executePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>/_execute', @@ -61,7 +61,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); - slm.updatePolicy = ca({ + sr.updatePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -75,7 +75,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); - slm.executeRetention = ca({ + sr.executeRetention = ca({ urls: [ { fmt: '/_slm/_execute_retention', @@ -83,4 +83,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'POST', }); + + sr.cleanupRepository = ca({ + urls: [ + { + fmt: '/_snapshot/<%=name%>/_cleanup', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'POST', + }); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index bbfc82b8a6de9a..9f434ac10c16a8 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -40,7 +40,7 @@ export const getAllHandler: RouterRouteHandler = async ( // Get policies const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policies', { + } = await callWithRequest('sr.policies', { human: true, }); @@ -62,7 +62,7 @@ export const getOneHandler: RouterRouteHandler = async ( const { name } = req.params; const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policy', { + } = await callWithRequest('sr.policy', { name, human: true, }); @@ -82,7 +82,7 @@ export const getOneHandler: RouterRouteHandler = async ( export const executeHandler: RouterRouteHandler = async (req, callWithRequest) => { const { name } = req.params; - const { snapshot_name: snapshotName } = await callWithRequest('slm.executePolicy', { + const { snapshot_name: snapshotName } = await callWithRequest('sr.executePolicy', { name, }); return { snapshotName }; @@ -98,7 +98,7 @@ export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => await Promise.all( policyNames.map(name => { - return callWithRequest('slm.deletePolicy', { name }) + return callWithRequest('sr.deletePolicy', { name }) .then(() => response.itemsDeleted.push(name)) .catch(e => response.errors.push({ @@ -122,7 +122,7 @@ export const createHandler: RouterRouteHandler = async (req, callWithRequest) => // Check that policy with the same name doesn't already exist try { - const policyByName = await callWithRequest('slm.policy', { name }); + const policyByName = await callWithRequest('sr.policy', { name }); if (policyByName[name]) { throw conflictError; } @@ -134,7 +134,7 @@ export const createHandler: RouterRouteHandler = async (req, callWithRequest) => } // Otherwise create new policy - return await callWithRequest('slm.updatePolicy', { + return await callWithRequest('sr.updatePolicy', { name, body: serializePolicy(policy), }); @@ -146,10 +146,10 @@ export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => // Check that policy with the given name exists // If it doesn't exist, 404 will be thrown by ES and will be returned - await callWithRequest('slm.policy', { name }); + await callWithRequest('sr.policy', { name }); // Otherwise update policy - return await callWithRequest('slm.updatePolicy', { + return await callWithRequest('sr.updatePolicy', { name, body: serializePolicy(policy), }); @@ -210,5 +210,5 @@ export const updateRetentionSettingsHandler: RouterRouteHandler = async (req, ca }; export const executeRetentionHandler: RouterRouteHandler = async (_req, callWithRequest) => { - return await callWithRequest('slm.executeRetention'); + return await callWithRequest('sr.executeRetention'); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts index f6ac946ab07d5b..3d67494da4aad0 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts @@ -15,6 +15,7 @@ import { RepositoryType, RepositoryVerification, SlmPolicyEs, + RepositoryCleanup, } from '../../../common/types'; import { Plugins } from '../../shim'; @@ -34,6 +35,7 @@ export function registerRepositoriesRoutes(router: Router, plugins: Plugins) { router.get('repositories', getAllHandler); router.get('repositories/{name}', getOneHandler); router.get('repositories/{name}/verify', getVerificationHandler); + router.post('repositories/{name}/cleanup', getCleanupHandler); router.put('repositories', createHandler); router.put('repositories/{name}', updateHandler); router.delete('repositories/{names}', deleteHandler); @@ -74,7 +76,7 @@ export const getAllHandler: RouterRouteHandler = async ( try { const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policies', { + } = await callWithRequest('sr.policies', { human: true, }); const managedRepositoryPolicy = Object.entries(policiesByName) @@ -172,6 +174,31 @@ export const getVerificationHandler: RouterRouteHandler = async ( }; }; +export const getCleanupHandler: RouterRouteHandler = async ( + req, + callWithRequest +): Promise<{ + cleanup: RepositoryCleanup | {}; +}> => { + const { name } = req.params; + + const cleanupResults = await callWithRequest('sr.cleanupRepository', { + name, + }).catch(e => ({ + cleaned: false, + error: e.response ? JSON.parse(e.response) : e, + })); + + return { + cleanup: cleanupResults.error + ? cleanupResults + : { + cleaned: true, + response: cleanupResults, + }, + }; +}; + export const getTypesHandler: RouterRouteHandler = async () => { // In ECE/ESS, do not enable the default types const types: RepositoryType[] = isCloudEnabled ? [] : [...DEFAULT_REPOSITORY_TYPES]; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts index 042a2dfeaf6b53..0d34d6a6b1b313 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -38,7 +38,7 @@ export const getAllHandler: RouterRouteHandler = async ( // Attempt to retrieve policies // This could fail if user doesn't have access to read SLM policies try { - const policiesByName = await callWithRequest('slm.policies'); + const policiesByName = await callWithRequest('sr.policies'); policies = Object.keys(policiesByName); } catch (e) { // Silently swallow error as policy names aren't required in UI diff --git a/x-pack/legacy/plugins/snapshot_restore/server/shim.ts b/x-pack/legacy/plugins/snapshot_restore/server/shim.ts index 84c9ddf8e0bea6..d64f35c64f11e1 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/shim.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/shim.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { createRouter, Router } from '../../../server/lib/create_router'; import { registerLicenseChecker } from '../../../server/lib/register_license_checker'; -import { elasticsearchJsPlugin } from './client/elasticsearch_slm'; +import { elasticsearchJsPlugin } from './client/elasticsearch_sr'; import { CloudSetup } from '../../../../plugins/cloud/server'; export interface Core { http: { diff --git a/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts b/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts index fa8ae641686737..fbd39db6969bd4 100644 --- a/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/legacy/plugins/spaces/public/management/management_service.test.ts @@ -6,6 +6,12 @@ import { ManagementService } from '.'; +const mockSections = { + getSection: jest.fn(), + getAllSections: jest.fn(), + navigateToApp: jest.fn(), +}; + describe('ManagementService', () => { describe('#start', () => { it('registers the spaces management page under the kibana section', () => { @@ -18,6 +24,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { @@ -49,6 +56,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { @@ -66,6 +74,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(undefined), }, + sections: mockSections, }; const deps = { @@ -94,6 +103,7 @@ describe('ManagementService', () => { legacy: { getSection: jest.fn().mockReturnValue(mockKibanaSection), }, + sections: mockSections, }; const deps = { diff --git a/x-pack/legacy/plugins/uptime/README.md b/x-pack/legacy/plugins/uptime/README.md index 37e030c44db37e..308f78ecdc3689 100644 --- a/x-pack/legacy/plugins/uptime/README.md +++ b/x-pack/legacy/plugins/uptime/README.md @@ -3,7 +3,7 @@ ## Purpose The purpose of this plugin is to provide users of Heartbeat more visibility of what's happening -in their infrasturcture. It's primarily built using React and Apollo's GraphQL tools. +in their infrastructure. It's primarily built using React and Apollo's GraphQL tools. ## Layout @@ -44,7 +44,7 @@ From `~/kibana/x-pack`, run `node scripts/jest.js`. ### Functional tests In one shell, from **~/kibana/x-pack**: -`node scripts/functional_tests-server.js` +`node scripts/functional_tests_server.js` In another shell, from **~kibana/x-pack**: `node ../scripts/functional_test_runner.js --grep="{TEST_NAME}"`. diff --git a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json index 4d1993233e9ca6..19d9cf19cc7f84 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/introspection.json +++ b/x-pack/legacy/plugins/uptime/common/graphql/introspection.json @@ -352,25 +352,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "getMonitorPageTitle", - "description": "", - "args": [ - { - "name": "monitorId", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { "kind": "OBJECT", "name": "MonitorPageTitle", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "getMonitorStates", "description": "Fetches the current state of Uptime monitors for the given parameters.", @@ -2173,18 +2154,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "mixed", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Int", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "total", "description": "", @@ -2561,45 +2530,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "MonitorPageTitle", - "description": "", - "fields": [ - { - "name": "id", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "url", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, { "kind": "OBJECT", "name": "MonitorSummaryResult", diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index ed7c9ef19f484f..92e27d20323a76 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -30,9 +30,6 @@ export interface Query { /** Fetch the most recent event data for a monitor ID, date range, location. */ getLatestMonitors: Ping[]; - getFilterBar?: FilterBar | null; - - getMonitorPageTitle?: MonitorPageTitle | null; /** Fetches the current state of Uptime monitors for the given parameters. */ getMonitorStates?: MonitorSummaryResult | null; /** Fetches details about the uptime index. */ @@ -419,8 +416,6 @@ export interface SnapshotCount { down: number; - mixed: number; - total: number; } @@ -470,29 +465,7 @@ export interface StatusData { /** The total down counts for this point. */ total?: number | null; } -/** The data used to enrich the filter bar. */ -export interface FilterBar { - /** A series of monitor IDs in the heartbeat indices. */ - ids?: string[] | null; - /** The location values users have configured for the agents. */ - locations?: string[] | null; - /** The ports of the monitored endpoints. */ - ports?: number[] | null; - /** The schemes used by the monitors. */ - schemes?: string[] | null; - /** The possible status values contained in the indices. */ - statuses?: string[] | null; - /** The list of URLs */ - urls?: string[] | null; -} - -export interface MonitorPageTitle { - id: string; - - url?: string | null; - name?: string | null; -} /** The primary object returned for monitor states. */ export interface MonitorSummaryResult { /** Used to go to the next page of results */ @@ -738,24 +711,12 @@ export interface GetMonitorChartsDataQueryArgs { location?: string | null; } -export interface GetLatestMonitorsQueryArgs { - /** The lower limit of the date range. */ - dateRangeStart: string; - /** The upper limit of the date range. */ - dateRangeEnd: string; - /** Optional: a specific monitor ID filter. */ - monitorId?: string | null; - /** Optional: a specific instance location filter. */ - location?: string | null; -} export interface GetFilterBarQueryArgs { dateRangeStart: string; dateRangeEnd: string; } -export interface GetMonitorPageTitleQueryArgs { - monitorId: string; -} + export interface GetMonitorStatesQueryArgs { dateRangeStart: string; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index 224892eb917839..58f79abcf91ecd 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -5,6 +5,6 @@ */ export * from './common'; +export * from './monitor'; +export * from './overview_filters'; export * from './snapshot'; -export * from './monitor/monitor_details'; -export * from './monitor/monitor_locations'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_details.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/details.ts similarity index 99% rename from x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_details.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/monitor/details.ts index 0a57a67b898e07..bf81c91bae6339 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_details.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/details.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import * as t from 'io-ts'; // IO type for validation diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/index.ts new file mode 100644 index 00000000000000..80b48d09dc5b80 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './details'; +export * from './locations'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_locations.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts similarity index 100% rename from x-pack/legacy/plugins/uptime/common/runtime_types/monitor/monitor_locations.ts rename to x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts new file mode 100644 index 00000000000000..a803a0720959a3 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { OverviewFiltersType, OverviewFilters } from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts new file mode 100644 index 00000000000000..9b9241494f0012 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const OverviewFiltersType = t.type({ + locations: t.array(t.string), + ports: t.array(t.number), + schemes: t.array(t.string), + tags: t.array(t.string), +}); + +export type OverviewFilters = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts index d4935c50ff5b8d..3abc25530a2dcf 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/snapshot/snapshot_count.ts @@ -8,7 +8,6 @@ import * as t from 'io-ts'; export const SnapshotType = t.type({ down: t.number, - mixed: t.number, total: t.number, up: t.number, }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap deleted file mode 100644 index e18846e960122a..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/empty_status_bar.test.tsx.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmptyStatusBar component renders a default message when no message provided 1`] = ` - - - - No data found for monitor id mon_id - - - -`; - -exports[`EmptyStatusBar component renders a message when provided 1`] = ` - - - - foobarbaz - - - -`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap index 45c24fd11194df..d731a168225b7b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap @@ -5,22 +5,14 @@ Array [
, - .c0 { - margin-left: 20px; -} - -
-
- SSL certificate expires in 2 months -
+ SSL certificate expires in 2 months
, ] diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap index e53235b1ed6f70..17588ae53ed00d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_status.bar.test.tsx.snap @@ -2,46 +2,24 @@ exports[`MonitorStatusBar component renders duration in ms, not us 1`] = `
-
-
- -
-
- Up -
-
+

+ Up in 2 Locations +

- 1234ms -
-
- 15 minutes ago + +

+ id1 +

+
+
+
`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap index a38ca85a3e9e75..9a4cb2e04f59b0 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/uptime_date_picker.test.tsx.snap @@ -227,54 +227,24 @@ exports[`UptimeDatePicker component validates props with shallow render 1`] = ` commonlyUsedRanges={ Array [ Object { - "end": "now/d", + "end": "now", "label": "Today", "start": "now/d", }, - Object { - "end": "now/w", - "label": "This week", - "start": "now/w", - }, - Object { - "end": "now", - "label": "Last 15 minutes", - "start": "now-15m", - }, - Object { - "end": "now", - "label": "Last 30 minutes", - "start": "now-30m", - }, Object { "end": "now", - "label": "Last 1 hour", - "start": "now-1h", - }, - Object { - "end": "now", - "label": "Last 24 hours", - "start": "now-24h", - }, - Object { - "end": "now", - "label": "Last 7 days", - "start": "now-7d", - }, - Object { - "end": "now", - "label": "Last 30 days", - "start": "now-30d", + "label": "Week to date", + "start": "now/w", }, Object { "end": "now", - "label": "Last 90 days", - "start": "now-90d", + "label": "Month to date", + "start": "now/M", }, Object { "end": "now", - "label": "Last 2 year", - "start": "now-1y", + "label": "Year to date", + "start": "now/y", }, ] } diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx deleted file mode 100644 index b815f0e38b8e27..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/empty_status_bar.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { EmptyStatusBar } from '../empty_status_bar'; - -describe('EmptyStatusBar component', () => { - it('renders a message when provided', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('renders a default message when no message provided', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx index c4063208d046ec..03eb252aa8c097 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import moment from 'moment'; import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { PingTls } from '../../../../common/graphql/types'; -import { MonitorSSLCertificate } from '../monitor_status_bar'; +import { MonitorSSLCertificate } from '../monitor_status_details/monitor_status_bar'; describe('MonitorStatusBar component', () => { let monitorTls: PingTls; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx index 761dd8a65238fd..545405f91d5374 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_status.bar.test.tsx @@ -8,34 +8,59 @@ import moment from 'moment'; import React from 'react'; import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { Ping } from '../../../../common/graphql/types'; -import { MonitorStatusBarComponent } from '../monitor_status_bar'; +import { MonitorStatusBarComponent } from '../monitor_status_details/monitor_status_bar'; describe('MonitorStatusBar component', () => { - let monitorStatus: Ping[]; + let monitorStatus: Ping; + let monitorLocations: any; + let dateStart: string; + let dateEnd: string; beforeEach(() => { - monitorStatus = [ - { - id: 'id1', - timestamp: moment(new Date()) - .subtract(15, 'm') - .toString(), - monitor: { - duration: { - us: 1234567, - }, - status: 'up', - }, - url: { - full: 'https://www.example.com/', + monitorStatus = { + id: 'id1', + timestamp: moment(new Date()) + .subtract(15, 'm') + .toString(), + monitor: { + duration: { + us: 1234567, }, + status: 'up', + }, + url: { + full: 'https://www.example.com/', }, - ]; + }; + + monitorLocations = { + monitorId: 'secure-avc', + locations: [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ], + }; + + dateStart = moment('01-01-2010').toString(); + dateEnd = moment('10-10-2010').toString(); }); it('renders duration in ms, not us', () => { const component = renderWithIntl( - + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx index 193f37c8fe56b5..d645eb21ac7766 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/snapshot.test.tsx @@ -13,7 +13,6 @@ describe('Snapshot component', () => { const snapshot: Snapshot = { up: 8, down: 2, - mixed: 0, total: 10, }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx index 93fa0b505a891f..e3ca1a87850c8a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/uptime_date_picker.test.tsx @@ -6,37 +6,16 @@ import { shallowWithIntl, renderWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { UptimeDatePicker, CommonlyUsedRange } from '../uptime_date_picker'; +import { UptimeDatePicker } from '../uptime_date_picker'; describe('UptimeDatePicker component', () => { - let commonlyUsedRange: CommonlyUsedRange[]; - - beforeEach(() => { - commonlyUsedRange = [ - { from: 'now/d', to: 'now/d', display: 'Today' }, - { from: 'now/w', to: 'now/w', display: 'This week' }, - { from: 'now-15m', to: 'now', display: 'Last 15 minutes' }, - { from: 'now-30m', to: 'now', display: 'Last 30 minutes' }, - { from: 'now-1h', to: 'now', display: 'Last 1 hour' }, - { from: 'now-24h', to: 'now', display: 'Last 24 hours' }, - { from: 'now-7d', to: 'now', display: 'Last 7 days' }, - { from: 'now-30d', to: 'now', display: 'Last 30 days' }, - { from: 'now-90d', to: 'now', display: 'Last 90 days' }, - { from: 'now-1y', to: 'now', display: 'Last 2 year' }, - ]; - }); - it('validates props with shallow render', () => { - const component = shallowWithIntl( - - ); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); it('renders properly with mock data', () => { - const component = renderWithIntl( - - ); + const component = renderWithIntl(); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap index c8c4177a4907e9..9699a1842ccf14 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart.test.tsx.snap @@ -103,6 +103,7 @@ exports[`DonutChart component renders a donut chart 1`] = ` 32 @@ -150,6 +151,7 @@ exports[`DonutChart component renders a donut chart 1`] = ` 95 diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap index 3674497b538f38..e971576521b7d5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend.test.tsx.snap @@ -5,6 +5,7 @@ exports[`DonutChartLegend applies valid props as expected 1`] = ` diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap index 5f6508e299a28b..bc6033ea7109ab 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/donut_chart_legend_row.test.tsx.snap @@ -21,6 +21,7 @@ exports[`DonutChartLegendRow passes appropriate props 1`] = ` 23 diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx index 82e2279edd8926..49e887cc8f96c2 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/donut_chart_legend_row.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; describe('DonutChartLegendRow', () => { it('passes appropriate props', () => { const wrapper = shallowWithIntl( - + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx index e2705e7cbacb31..50dca8577455d3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart.tsx @@ -73,7 +73,7 @@ export const DonutChart = ({ height, down, up, width }: DonutChartProps) => { { ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx index bf684a3446b9a8..fc67a86db3b487 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/donut_chart_legend_row.tsx @@ -23,9 +23,10 @@ interface Props { color: string; message: string; content: string | number; + 'data-test-subj': string; } -export const DonutChartLegendRow = ({ color, content, message }: Props) => ( +export const DonutChartLegendRow = ({ color, content, message, 'data-test-subj': dts }: Props) => ( @@ -33,6 +34,8 @@ export const DonutChartLegendRow = ({ color, content, message }: Props) => ( {message} - {content} + + {content} + ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap index 4dfc837c29b55c..36c54758cf116b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/data_missing.test.tsx.snap @@ -20,7 +20,7 @@ exports[`DataMissing component renders basePath and headingMessage 1`] = ` values={ Object { "configureHeartbeatLink": @@ -301,7 +299,6 @@ exports[`EmptyState component does not render empty state with appropriate base exports[`EmptyState component doesn't render child components when count is falsey 1`] = ` @@ -783,7 +778,6 @@ exports[`EmptyState component renders child components when count is truthy 1`] exports[`EmptyState component renders error message when an error occurs 1`] = ` { it('renders basePath and headingMessage', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx index 0077292d91a467..32d55519acf276 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx @@ -24,7 +24,7 @@ describe('EmptyState component', () => { it('renders child components when count is truthy', () => { const component = shallowWithIntl( - +
Foo
Bar
Baz
@@ -35,7 +35,7 @@ describe('EmptyState component', () => { it(`doesn't render child components when count is falsey`, () => { const component = mountWithIntl( - +
Shouldn't be rendered
); @@ -57,7 +57,7 @@ describe('EmptyState component', () => { }, ]; const component = mountWithIntl( - +
Shouldn't appear...
); @@ -66,7 +66,7 @@ describe('EmptyState component', () => { it('renders loading state if no errors or doc count', () => { const component = mountWithIntl( - +
Should appear even while loading...
); @@ -81,7 +81,7 @@ describe('EmptyState component', () => { indexExists: true, }; const component = mountWithIntl( - +
If this is in the snapshot the test should fail
); @@ -91,7 +91,7 @@ describe('EmptyState component', () => { it('notifies when index does not exist', () => { statesIndexStatus.indexExists = false; const component = mountWithIntl( - +
This text should not render
); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx index 023edec023ddf8..f8110953f61460 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/data_missing.tsx @@ -14,48 +14,51 @@ import { EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useContext } from 'react'; +import { UptimeSettingsContext } from '../../../contexts'; interface DataMissingProps { - basePath: string; headingMessage: string; } -export const DataMissing = ({ basePath, headingMessage }: DataMissingProps) => ( - - - - - -

{headingMessage}

- - } - body={ -

- - - - ), - }} - /> -

- } - /> -
-
-
-); +export const DataMissing = ({ headingMessage }: DataMissingProps) => { + const { basePath } = useContext(UptimeSettingsContext); + return ( + + + + + +

{headingMessage}

+ + } + body={ +

+ + + + ), + }} + /> +

+ } + /> +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx index 2a39226c5b746e..d2d46dff3b9f52 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx @@ -18,13 +18,12 @@ interface EmptyStateQueryResult { } interface EmptyStateProps { - basePath: string; children: JSX.Element[] | JSX.Element; } type Props = UptimeGraphQLQueryProps & EmptyStateProps; -export const EmptyStateComponent = ({ basePath, children, data, errors }: Props) => { +export const EmptyStateComponent = ({ children, data, errors }: Props) => { if (errors) { return ; } @@ -33,7 +32,6 @@ export const EmptyStateComponent = ({ basePath, children, data, errors }: Props) if (!indexExists) { return ( ( - - - - {!message - ? i18n.translate('xpack.uptime.emptyStatusBar.defaultMessage', { - defaultMessage: 'No data found for monitor id {monitorId}', - description: - 'This is the default message we display in a status bar when there is no data available for an uptime monitor.', - values: { monitorId }, - }) - : message} - - - -); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap index 2022390d0e5d9c..0e6ea3662b97e5 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap @@ -13,6 +13,7 @@ exports[`FilterPopover component does not show item list when loading 1`] = ` /> } closePopover={[Function]} + data-test-subj="filter-popover_test" display="inlineBlock" hasArrow={true} id="test" @@ -49,6 +50,7 @@ exports[`FilterPopover component renders without errors for valid props 1`] = ` /> } closePopover={[Function]} + data-test-subj="filter-popover_test" display="inlineBlock" hasArrow={true} id="test" @@ -83,6 +85,7 @@ exports[`FilterPopover component returns selected items on popover close 1`] = `
{ props = { fieldName: 'foo', id: 'test', - isLoading: false, + loading: false, items: ['first', 'second', 'third', 'fourth'], onFilterFieldChange: jest.fn(), selectedItems: ['first', 'third'], @@ -47,7 +47,7 @@ describe('FilterPopover component', () => { }); it('does not show item list when loading', () => { - props.isLoading = true; + props.loading = true; const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts new file mode 100644 index 00000000000000..8deee25377850f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parseFiltersMap } from '../parse_filter_map'; + +describe('parseFiltersMap', () => { + it('provides values from valid filter string', () => { + expect( + parseFiltersMap( + '[["url.port",["5601","80"]],["observer.geo.name",["us-east-2"]],["monitor.type",["http","tcp"]]]' + ) + ).toMatchSnapshot(); + }); + + it('returns an empty object for invalid filter', () => { + expect(() => parseFiltersMap('some invalid string')).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx index f27514bf76a11a..351302fb383561 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx @@ -5,35 +5,49 @@ */ import { EuiFilterGroup } from '@elastic/eui'; -import React from 'react'; -import { get } from 'lodash'; +import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { FilterBar as FilterBarType } from '../../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; -import { filterBarQuery } from '../../../queries'; +import { connect } from 'react-redux'; import { FilterPopoverProps, FilterPopover } from './filter_popover'; import { FilterStatusButton } from './filter_status_button'; +import { OverviewFilters } from '../../../../common/runtime_types'; +import { fetchOverviewFilters, GetOverviewFiltersPayload } from '../../../state/actions'; +import { AppState } from '../../../state'; +import { useUrlParams } from '../../../hooks'; +import { parseFiltersMap } from './parse_filter_map'; -interface FilterBarQueryResult { - filters?: FilterBarType; +interface OwnProps { + currentFilter: any; + onFilterUpdate: any; + dateRangeStart: string; + dateRangeEnd: string; + filters?: string; + statusFilter?: string; } -interface FilterBarDropdownsProps { - currentFilter: string; - onFilterUpdate: (kuery: string) => void; +interface StoreProps { + esKuery: string; + lastRefresh: number; + loading: boolean; + overviewFilters: OverviewFilters; } -type Props = UptimeGraphQLQueryProps & FilterBarDropdownsProps; +interface DispatchProps { + loadFilterGroup: typeof fetchOverviewFilters; +} + +type Props = OwnProps & StoreProps & DispatchProps; + +type PresentationalComponentProps = Pick & + Pick; -export const FilterGroupComponent = ({ - loading: isLoading, +export const PresentationalComponent: React.FC = ({ currentFilter, - data, + overviewFilters, + loading, onFilterUpdate, -}: Props) => { - const locations = get(data, 'filterBar.locations', []); - const ports = get(data, 'filterBar.ports', []); - const schemes = get(data, 'filterBar.schemes', []); +}) => { + const { locations, ports, schemes, tags } = overviewFilters; let filterKueries: Map; try { @@ -67,36 +81,50 @@ export const FilterGroupComponent = ({ const filterPopoverProps: FilterPopoverProps[] = [ { + loading, + onFilterFieldChange, fieldName: 'observer.geo.name', id: 'location', - isLoading, items: locations, - onFilterFieldChange, selectedItems: getSelectedItems('observer.geo.name'), title: i18n.translate('xpack.uptime.filterBar.options.location.name', { defaultMessage: 'Location', }), }, { + loading, + onFilterFieldChange, fieldName: 'url.port', id: 'port', - isLoading, - items: ports, - onFilterFieldChange, + disabled: ports.length === 0, + items: ports.map((p: number) => p.toString()), selectedItems: getSelectedItems('url.port'), title: i18n.translate('xpack.uptime.filterBar.options.portLabel', { defaultMessage: 'Port' }), }, { + loading, + onFilterFieldChange, fieldName: 'monitor.type', id: 'scheme', - isLoading, + disabled: schemes.length === 0, items: schemes, - onFilterFieldChange, selectedItems: getSelectedItems('monitor.type'), title: i18n.translate('xpack.uptime.filterBar.options.schemeLabel', { defaultMessage: 'Scheme', }), }, + { + loading, + onFilterFieldChange, + fieldName: 'tags', + id: 'tags', + disabled: tags.length === 0, + items: tags, + selectedItems: getSelectedItems('tags'), + title: i18n.translate('xpack.uptime.filterBar.options.tagsLabel', { + defaultMessage: 'Tags', + }), + }, ]; return ( @@ -124,7 +152,59 @@ export const FilterGroupComponent = ({ ); }; -export const FilterGroup = withUptimeGraphQL( - FilterGroupComponent, - filterBarQuery -); +export const Container: React.FC = ({ + currentFilter, + esKuery, + filters, + loading, + loadFilterGroup, + dateRangeStart, + dateRangeEnd, + overviewFilters, + statusFilter, + onFilterUpdate, +}: Props) => { + const [getUrlParams] = useUrlParams(); + const { filters: urlFilters } = getUrlParams(); + useEffect(() => { + const filterSelections = parseFiltersMap(urlFilters); + loadFilterGroup({ + dateRangeStart, + dateRangeEnd, + locations: filterSelections.locations ?? [], + ports: filterSelections.ports ?? [], + schemes: filterSelections.schemes ?? [], + search: esKuery, + statusFilter, + tags: filterSelections.tags ?? [], + }); + }, [dateRangeStart, dateRangeEnd, esKuery, filters, statusFilter, urlFilters, loadFilterGroup]); + return ( + + ); +}; + +const mapStateToProps = ({ + overviewFilters: { loading, filters }, + ui: { esKuery, lastRefresh }, +}: AppState): StoreProps => ({ + esKuery, + overviewFilters: filters, + lastRefresh, + loading, +}); + +const mapDispatchToProps = (dispatch: any): DispatchProps => ({ + loadFilterGroup: (payload: GetOverviewFiltersPayload) => dispatch(fetchOverviewFilters(payload)), +}); + +export const FilterGroup = connect( + // @ts-ignore connect is expecting null | undefined for some reason + mapStateToProps, + mapDispatchToProps +)(Container); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx index 6e73090782b042..f96fef609fe76d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx @@ -14,7 +14,8 @@ import { LocationLink } from '../monitor_list'; export interface FilterPopoverProps { fieldName: string; id: string; - isLoading: boolean; + loading: boolean; + disabled?: boolean; items: string[]; onFilterFieldChange: (fieldName: string, values: string[]) => void; selectedItems: string[]; @@ -27,7 +28,8 @@ const isItemSelected = (selectedItems: string[], item: string): 'on' | undefined export const FilterPopover = ({ fieldName, id, - isLoading, + disabled, + loading, items, onFilterFieldChange, selectedItems, @@ -48,10 +50,10 @@ export const FilterPopover = ({ }, [searchQuery, items]); return ( - // @ts-ignore zIndex prop is not described in the typing yet 0} numFilters={items.length} numActiveFilters={tempSelectedItems.length} @@ -66,6 +68,7 @@ export const FilterPopover = ({ setIsOpen(false); onFilterFieldChange(fieldName, tempSelectedItems); }} + data-test-subj={`filter-popover_${id}`} id={id} isOpen={isOpen} ownFocus={true} @@ -77,7 +80,7 @@ export const FilterPopover = ({ disabled={items.length === 0} onSearch={query => setSearchQuery(query)} placeholder={ - isLoading + loading ? i18n.translate('xpack.uptime.filterPopout.loadingMessage', { defaultMessage: 'Loading...', }) @@ -90,10 +93,11 @@ export const FilterPopover = ({ } /> - {!isLoading && + {!loading && itemsToDisplay.map(item => ( toggleSelectedItems(item, tempSelectedItems, setTempSelectedItems)} > diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx index 95f4c30337d627..abbe72530fd80a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx @@ -11,6 +11,7 @@ import { useUrlParams } from '../../../hooks'; export interface FilterStatusButtonProps { content: string; dataTestSubj: string; + isDisabled?: boolean; value: string; withNext: boolean; } @@ -18,6 +19,7 @@ export interface FilterStatusButtonProps { export const FilterStatusButton = ({ content, dataTestSubj, + isDisabled, value, withNext, }: FilterStatusButtonProps) => { @@ -27,6 +29,7 @@ export const FilterStatusButton = ({ { const nextFilter = { statusFilter: urlValue === value ? '' : value, pagination: '' }; setUrlParams(nextFilter); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts new file mode 100644 index 00000000000000..08766521799ea6 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface FilterField { + name: string; + fieldName: string; +} + +/** + * These are the only filter fields we are looking to catch at the moment. + * If your code needs to support custom fields, introduce a second parameter to + * `parseFiltersMap` to take a list of FilterField objects. + */ +const filterWhitelist: FilterField[] = [ + { name: 'ports', fieldName: 'url.port' }, + { name: 'locations', fieldName: 'observer.geo.name' }, + { name: 'tags', fieldName: 'tags' }, + { name: 'schemes', fieldName: 'monitor.type' }, +]; + +export const parseFiltersMap = (filterMapString: string) => { + if (!filterMapString) { + return {}; + } + const filterSlices: { [key: string]: any } = {}; + try { + const map = new Map(JSON.parse(filterMapString)); + filterWhitelist.forEach(({ name, fieldName }) => { + filterSlices[name] = map.get(fieldName) ?? []; + }); + return filterSlices; + } catch { + throw new Error('Unable to parse invalid filter string'); + } +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx index fc0c6342bd6e90..0e05c17d57353a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx @@ -8,6 +8,7 @@ import { EuiFilterButton } from '@elastic/eui'; import React from 'react'; interface UptimeFilterButtonProps { + isDisabled?: boolean; isSelected: boolean; numFilters: number; numActiveFilters: number; @@ -16,6 +17,7 @@ interface UptimeFilterButtonProps { } export const UptimeFilterButton = ({ + isDisabled, isSelected, numFilters, numActiveFilters, @@ -25,6 +27,7 @@ export const UptimeFilterButton = ({ { return { @@ -17,6 +18,7 @@ jest.mock('uuid', () => { describe('map_config', () => { let upPoints: LocationPoint[]; let downPoints: LocationPoint[]; + let colors: Pick; beforeEach(() => { upPoints = [ @@ -29,11 +31,15 @@ describe('map_config', () => { { lat: '55.487239', lon: '13.399262' }, { lat: '54.487239', lon: '14.399262' }, ]; + colors = { + danger: '#BC261E', + gray: '#000', + }; }); describe('#getLayerList', () => { test('it returns the low poly layer', () => { - const layerList = getLayerList(upPoints, downPoints, { danger: '#BC261E', gray: '#000' }); + const layerList = getLayerList(upPoints, downPoints, colors); expect(layerList).toStrictEqual(mockLayerList); }); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx index fe8a1a0bad7ec6..9b20651fadb86b 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/embedded_map.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext, useRef } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; @@ -48,19 +48,31 @@ const EmbeddedPanel = styled.div` export const EmbeddedMap = ({ upPoints, downPoints }: EmbeddedMapProps) => { const { colors } = useContext(UptimeSettingsContext); const [embeddable, setEmbeddable] = useState(); - const embeddableRoot: React.RefObject = React.createRef(); + const embeddableRoot: React.RefObject = useRef(null); const factory = start.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE); const input = { id: uuid.v4(), filters: [], hidePanelTitles: true, - query: { query: '', language: 'kuery' }, - refreshConfig: { value: 0, pause: false }, + query: { + query: '', + language: 'kuery', + }, + refreshConfig: { + value: 0, + pause: false, + }, viewMode: 'view', isLayerTOCOpen: false, hideFilterActions: true, - mapCenter: { lon: 11, lat: 20, zoom: 0 }, + // Zoom Lat/Lon values are set to make sure map is in center in the panel + // It wil also omit Greenland/Antarctica etc + mapCenter: { + lon: 11, + lat: 20, + zoom: 0, + }, disableInteractive: true, disableTooltipControl: true, hideToolbarOverlay: true, @@ -80,16 +92,19 @@ export const EmbeddedMap = ({ upPoints, downPoints }: EmbeddedMapProps) => { setEmbeddable(embeddableObject); } setupEmbeddable(); + // we want this effect to execute exactly once after the component mounts // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // update map layers based on points useEffect(() => { if (embeddable) { embeddable.setLayerList(getLayerList(upPoints, downPoints, colors)); } }, [upPoints, downPoints, embeddable, colors]); + // We can only render after embeddable has already initialized useEffect(() => { if (embeddableRoot.current && embeddable) { embeddable.render(embeddableRoot.current); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts index b423b8baf41bf6..d4601baefdf30c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/embeddables/map_config.ts @@ -6,6 +6,7 @@ import lowPolyLayerFeatures from './low_poly_layer.json'; import { LocationPoint } from './embedded_map'; +import { UptimeAppColors } from '../../../../uptime_app'; /** * Returns `Source/Destination Point-to-point` Map LayerList configuration, with a source, @@ -15,7 +16,7 @@ import { LocationPoint } from './embedded_map'; export const getLayerList = ( upPoints: LocationPoint[], downPoints: LocationPoint[], - { gray, danger }: { gray: string; danger: string } + { gray, danger }: Pick ) => { return [getLowPolyLayer(), getDownPointsLayer(downPoints, danger), getUpPointsLayer(upPoints)]; }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx index f70d145ec05c38..9a9bf3fe71dc17 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_map.tsx @@ -11,10 +11,12 @@ import { LocationStatusTags } from './location_status_tags'; import { EmbeddedMap, LocationPoint } from './embeddables/embedded_map'; import { MonitorLocations } from '../../../../common/runtime_types'; +// These height/width values are used to make sure map is in center of panel +// And to make sure, it doesn't take too much space const MapPanel = styled.div` height: 240px; width: 520px; - margin-right: 10px; + margin-right: 20px; `; interface LocationMapProps { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json index 64adf3642fb228..a45e974685b9c9 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_drawer/__tests__/data.json @@ -271,7 +271,6 @@ "monitor": { "id": null, "name": "elastic", - "status": "mixed", "type": null, "__typename": "MonitorState" }, @@ -377,7 +376,6 @@ "monitor": { "id": null, "name": null, - "status": "mixed", "type": null, "__typename": "MonitorState" }, diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx index 463048512e1e0f..0a3a0962a4d09a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_status_column.tsx @@ -20,8 +20,6 @@ const getHealthColor = (status: string): string => { return 'success'; case 'down': return 'danger'; - case 'mixed': - return 'warning'; default: return ''; } @@ -37,10 +35,6 @@ const getHealthMessage = (status: string): string | null => { return i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', { defaultMessage: 'Down', }); - case 'mixed': - return i18n.translate('xpack.uptime.monitorList.statusColumn.mixedLabel', { - defaultMessage: 'Mixed', - }); default: return null; } diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx deleted file mode 100644 index 9e30a3adbd776a..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_page_title.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTextColor, EuiTitle } from '@elastic/eui'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import React from 'react'; -import { MonitorPageTitle as TitleType } from '../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../higher_order'; -import { monitorPageTitleQuery } from '../../queries'; - -interface MonitorPageTitleQueryResult { - monitorPageTitle?: TitleType; -} - -interface MonitorPageTitleProps { - monitorId: string; -} - -type Props = MonitorPageTitleProps & UptimeGraphQLQueryProps; - -export const MonitorPageTitleComponent = ({ data }: Props) => - data && data.monitorPageTitle ? ( - - -

{data.monitorPageTitle.id}

-
-
- ) : ( - - ); - -export const MonitorPageTitle = withUptimeGraphQL< - MonitorPageTitleQueryResult, - MonitorPageTitleProps ->(MonitorPageTitleComponent, monitorPageTitleQuery); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx deleted file mode 100644 index f36f0dff6745f8..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_status_bar.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiLink } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { get } from 'lodash'; -import moment from 'moment'; -import React from 'react'; -import { Ping } from '../../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; -import { monitorStatusBarQuery } from '../../../queries'; -import { EmptyStatusBar } from '../empty_status_bar'; -import { convertMicrosecondsToMilliseconds } from '../../../lib/helper'; -import { MonitorSSLCertificate } from './monitor_ssl_certificate'; -import * as labels from './translations'; - -interface MonitorStatusBarQueryResult { - monitorStatus?: Ping[]; -} - -interface MonitorStatusBarProps { - monitorId: string; -} - -type Props = MonitorStatusBarProps & UptimeGraphQLQueryProps; - -export const MonitorStatusBarComponent = ({ data, monitorId }: Props) => { - if (data?.monitorStatus?.length) { - const { monitor, timestamp, tls } = data.monitorStatus[0]; - const duration: number | undefined = get(monitor, 'duration.us', undefined); - const status = get<'up' | 'down'>(monitor, 'status', 'down'); - const full = get(data.monitorStatus[0], 'url.full'); - - return ( - <> - - - - {status === 'up' ? labels.upLabel : labels.downLabel} - - - - - - {full} - - - - {!!duration && ( - - - - )} - - {moment(new Date(timestamp).valueOf()).fromNow()} - - - - - ); - } - return ; -}; - -export const MonitorStatusBar = withUptimeGraphQL< - MonitorStatusBarQueryResult, - MonitorStatusBarProps ->(MonitorStatusBarComponent, monitorStatusBarQuery); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap new file mode 100644 index 00000000000000..c6b18501ffa0f3 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/status_by_location.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StatusByLocation component renders all locations are down 1`] = ` +
+

+ Down in 2 Locations +

+
+`; + +exports[`StatusByLocation component renders when down in some locations 1`] = ` +
+

+ Down in 1/2 Locations +

+
+`; + +exports[`StatusByLocation component renders when only one location and it is down 1`] = ` +
+

+ Down in 1 Location +

+
+`; + +exports[`StatusByLocation component renders when only one location and it is up 1`] = ` +
+

+ Up in 1 Location +

+
+`; + +exports[`StatusByLocation component renders when up in all locations 1`] = ` +
+

+ Up in 2 Locations +

+
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx new file mode 100644 index 00000000000000..4e515a52b8de6f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { MonitorLocation } from '../../../../../common/runtime_types'; +import { StatusByLocations } from '../'; + +describe('StatusByLocation component', () => { + let monitorLocations: MonitorLocation[]; + + it('renders when up in all locations', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when only one location and it is up', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when only one location and it is down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 4 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders all locations are down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 4 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 0, down: 4 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when down in some locations', () => { + monitorLocations = [ + { + summary: { up: 0, down: 4 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts index 234586e0b51f13..7b4e1ea353c11c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/index.ts @@ -5,12 +5,12 @@ */ import { connect } from 'react-redux'; import { AppState } from '../../../state'; -import { getMonitorLocations } from '../../../state/selectors'; +import { selectMonitorLocations } from '../../../state/selectors'; import { fetchMonitorLocations } from '../../../state/actions/monitor'; import { MonitorStatusDetailsComponent } from './monitor_status_details'; const mapStateToProps = (state: AppState, { monitorId }: any) => ({ - monitorLocations: getMonitorLocations(state, monitorId), + monitorLocations: selectMonitorLocations(state, monitorId), }); const mapDispatchToProps = (dispatch: any, ownProps: any) => ({ @@ -32,3 +32,5 @@ export const MonitorStatusDetails = connect( )(MonitorStatusDetailsComponent); export * from './monitor_status_details'; +export { MonitorStatusBar } from './monitor_status_bar'; +export { StatusByLocations } from './monitor_status_bar/status_by_location'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts new file mode 100644 index 00000000000000..94bd7fa7f026bc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/index.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import { + StateProps, + DispatchProps, + MonitorStatusBarComponent, + MonitorStatusBarProps, +} from './monitor_status_bar'; +import { selectMonitorStatus, selectMonitorLocations } from '../../../../state/selectors'; +import { AppState } from '../../../../state'; +import { getMonitorStatus, getSelectedMonitor } from '../../../../state/actions'; + +const mapStateToProps = (state: AppState, ownProps: MonitorStatusBarProps) => ({ + monitorStatus: selectMonitorStatus(state), + monitorLocations: selectMonitorLocations(state, ownProps.monitorId), +}); + +const mapDispatchToProps = (dispatch: Dispatch, ownProps: MonitorStatusBarProps) => ({ + loadMonitorStatus: () => { + const { dateStart, dateEnd, monitorId } = ownProps; + dispatch( + getMonitorStatus({ + monitorId, + dateStart, + dateEnd, + }) + ); + dispatch( + getSelectedMonitor({ + monitorId, + }) + ); + }, +}); + +// @ts-ignore TODO: Investigate typescript issues here +export const MonitorStatusBar = connect( + // @ts-ignore TODO: Investigate typescript issues here + mapStateToProps, + mapDispatchToProps +)(MonitorStatusBarComponent); + +export { MonitorSSLCertificate } from './monitor_ssl_certificate'; +export * from './monitor_status_bar'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_ssl_certificate.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx similarity index 53% rename from x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_ssl_certificate.tsx rename to x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx index c50f7f1b00f0d9..5e916c40e712d7 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/monitor_ssl_certificate.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx @@ -10,9 +10,8 @@ import moment from 'moment'; import { EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { PingTls } from '../../../../common/graphql/types'; +import { PingTls } from '../../../../../common/graphql/types'; interface Props { /** @@ -21,10 +20,6 @@ interface Props { tls: PingTls | null | undefined; } -const TextContainer = styled.div` - margin-left: 20px; -`; - export const MonitorSSLCertificate = ({ tls }: Props) => { const certificateValidity: string | undefined = get( tls, @@ -37,27 +32,22 @@ export const MonitorSSLCertificate = ({ tls }: Props) => { return validExpiryDate && certificateValidity ? ( <> - - - - - + + + ) : null; }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx new file mode 100644 index 00000000000000..57ca909ffde55a --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_status_bar.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiLink, + EuiTitle, + EuiTextColor, + EuiSpacer, + EuiText, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { MonitorSSLCertificate } from './monitor_ssl_certificate'; +import * as labels from './translations'; +import { StatusByLocations } from './status_by_location'; +import { Ping } from '../../../../../common/graphql/types'; +import { MonitorLocations } from '../../../../../common/runtime_types'; + +export interface StateProps { + monitorStatus: Ping; + monitorLocations: MonitorLocations; +} + +export interface DispatchProps { + loadMonitorStatus: () => void; +} + +export interface MonitorStatusBarProps { + monitorId: string; + dateStart: string; + dateEnd: string; +} + +type Props = MonitorStatusBarProps & StateProps & DispatchProps; + +export const MonitorStatusBarComponent: React.FC = ({ + dateStart, + dateEnd, + monitorId, + loadMonitorStatus, + monitorStatus, + monitorLocations, +}) => { + useEffect(() => { + loadMonitorStatus(); + }, [dateStart, dateEnd, loadMonitorStatus]); + + const full = monitorStatus?.url?.full ?? ''; + + return ( + + + + + + + + {full} + + + + + + +

{monitorId}

+
+
+
+ + + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx new file mode 100644 index 00000000000000..461ffc10124fd3 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/status_by_location.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MonitorLocation } from '../../../../../common/runtime_types'; + +interface StatusByLocationsProps { + locations: MonitorLocation[]; +} + +export const StatusByLocations = ({ locations }: StatusByLocationsProps) => { + const upLocations: string[] = []; + const downLocations: string[] = []; + + if (locations) + locations.forEach((item: any) => { + if (item.summary.down === 0) { + upLocations.push(item.geo.name); + } else { + downLocations.push(item.geo.name); + } + }); + + let statusMessage = ''; + let status = ''; + if (downLocations.length === 0) { + // for Messaging like 'Up in 1 Location' or 'Up in 2 Locations' + statusMessage = `${locations.length}`; + status = 'Up'; + } else if (downLocations.length > 0) { + // for Messaging like 'Down in 1/2 Locations' + status = 'Down'; + statusMessage = `${downLocations.length}/${locations.length}`; + if (downLocations.length === locations.length) { + // for Messaging like 'Down in 2 Locations' + statusMessage = `${locations.length}`; + } + } + + return ( + +

+ {locations.length <= 1 ? ( + + ) : ( + + )} +

+
+ ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/translations.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/translations.ts similarity index 100% rename from x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/translations.ts rename to x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/translations.ts diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx index ed67c6364e9580..bb87497d335efd 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_details.tsx @@ -7,7 +7,7 @@ import React, { useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { LocationMap } from '../location_map'; -import { MonitorStatusBar } from '../monitor_status_bar'; +import { MonitorStatusBar } from './monitor_status_bar'; interface MonitorStatusBarProps { monitorId: string; @@ -34,7 +34,12 @@ export const MonitorStatusDetailsComponent = ({ - + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx index 4c1b482b198aff..90d716001cff96 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/snapshot.tsx @@ -43,7 +43,7 @@ interface StoreProps { /** * Contains functions that will dispatch actions used - * for this component's lifecyclel + * for this component's life cycle */ interface DispatchProps { loadSnapshotCount: typeof fetchSnapshotCount; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx index ebd0cd1e4ae850..c282ac9b9e1553 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/uptime_date_picker.tsx @@ -5,9 +5,10 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import React from 'react'; +import React, { useContext } from 'react'; import { useUrlParams } from '../../hooks'; import { CLIENT_DEFAULTS } from '../../../common/constants'; +import { UptimeSettingsContext } from '../../contexts'; // TODO: when EUI exports types for this, this should be replaced interface SuperDateRangePickerRangeChangedEvent { @@ -26,16 +27,14 @@ export interface CommonlyUsedRange { display: string; } -interface Props { +interface UptimeDatePickerProps { refreshApp: () => void; - commonlyUsedRanges?: CommonlyUsedRange[]; } -type UptimeDatePickerProps = Props; - -export const UptimeDatePicker = ({ refreshApp, commonlyUsedRanges }: UptimeDatePickerProps) => { +export const UptimeDatePicker = ({ refreshApp }: UptimeDatePickerProps) => { const [getUrlParams, updateUrl] = useUrlParams(); const { autorefreshInterval, autorefreshIsPaused, dateRangeStart, dateRangeEnd } = getUrlParams(); + const { commonlyUsedRanges } = useContext(UptimeSettingsContext); const euiCommonlyUsedRanges = commonlyUsedRanges ? commonlyUsedRanges.map( diff --git a/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts b/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts index 0fd9be952ed401..c656391678aa2c 100644 --- a/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts +++ b/x-pack/legacy/plugins/uptime/public/contexts/uptime_settings_context.ts @@ -9,6 +9,7 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { createContext } from 'react'; import { UptimeAppColors } from '../uptime_app'; import { CONTEXT_DEFAULTS } from '../../common/constants'; +import { CommonlyUsedRange } from '../components/functional/uptime_date_picker'; export interface UMSettingsContextValues { absoluteStartDate: number; @@ -23,7 +24,7 @@ export interface UMSettingsContextValues { isInfraAvailable: boolean; isLogsAvailable: boolean; refreshApp: () => void; - setHeadingText: (text: string) => void; + commonlyUsedRanges?: CommonlyUsedRange[]; } const { @@ -64,9 +65,6 @@ const defaultContext: UMSettingsContextValues = { refreshApp: () => { throw new Error('App refresh was not initialized, set it when you invoke the context'); }, - setHeadingText: () => { - throw new Error('setHeadingText was not initialized on UMSettingsContext.'); - }, }; export const UptimeSettingsContext = createContext(defaultContext); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/__snapshots__/parameterize_values.test.ts.snap b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/__snapshots__/parameterize_values.test.ts.snap new file mode 100644 index 00000000000000..39c28a87f5e71f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/__snapshots__/parameterize_values.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parameterizeValues parameterizes provided values for multiple fields 1`] = `"foo=bar&foo=baz&bar=foo&bar=baz"`; + +exports[`parameterizeValues parameterizes the provided values for a given field name 1`] = `"foo=bar&foo=baz"`; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/parameterize_values.test.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/parameterize_values.test.ts new file mode 100644 index 00000000000000..e550a1a6397e39 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/__tests__/parameterize_values.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parameterizeValues } from '../parameterize_values'; + +describe('parameterizeValues', () => { + let params: URLSearchParams; + + beforeEach(() => { + params = new URLSearchParams(); + }); + + it('parameterizes the provided values for a given field name', () => { + parameterizeValues(params, { foo: ['bar', 'baz'] }); + expect(params.toString()).toMatchSnapshot(); + }); + + it('parameterizes provided values for multiple fields', () => { + parameterizeValues(params, { foo: ['bar', 'baz'], bar: ['foo', 'baz'] }); + expect(params.toString()).toMatchSnapshot(); + }); + + it('returns an empty string when there are no values provided', () => { + parameterizeValues(params, { foo: [] }); + expect(params.toString()).toBe(''); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts index a4cfb1c51b0ecb..ced06ce7a1d7b6 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts @@ -9,6 +9,7 @@ export { convertMicrosecondsToMilliseconds } from './convert_measurements'; export * from './observability_integration'; export { getApiPath } from './get_api_path'; export { getChartDateLabel } from './charts'; +export { parameterizeValues } from './parameterize_values'; export { seriesHasDownValues } from './series_has_down_values'; export { stringifyKueries } from './stringify_kueries'; export { toStaticIndexPattern } from './to_static_index_pattern'; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts new file mode 100644 index 00000000000000..4c9fa6838c2eda --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const parameterizeValues = ( + params: URLSearchParams, + obj: Record +): void => { + Object.keys(obj).forEach(key => { + obj[key].forEach(val => { + params.append(key, val); + }); + }); +}; diff --git a/x-pack/legacy/plugins/uptime/public/pages/index.ts b/x-pack/legacy/plugins/uptime/public/pages/index.ts index bf5d55ebf17aee..a96be42eb0deea 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/index.ts +++ b/x-pack/legacy/plugins/uptime/public/pages/index.ts @@ -7,3 +7,4 @@ export { MonitorPage } from './monitor'; export { OverviewPage } from './overview'; export { NotFoundPage } from './not_found'; +export { PageHeader } from './page_header'; diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index c8334b2376bc17..1b4ad8d82ead18 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -5,60 +5,31 @@ */ import { EuiSpacer } from '@elastic/eui'; -import { ApolloQueryResult, OperationVariables, QueryOptions } from 'apollo-client'; -import gql from 'graphql-tag'; -import React, { Fragment, useContext, useEffect, useState } from 'react'; -import { getMonitorPageBreadcrumb } from '../breadcrumbs'; -import { MonitorCharts, MonitorPageTitle, PingList } from '../components/functional'; +import React, { Fragment, useContext, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { MonitorCharts, PingList } from '../components/functional'; import { UMUpdateBreadcrumbs } from '../lib/lib'; import { UptimeSettingsContext } from '../contexts'; import { useUptimeTelemetry, useUrlParams, UptimePage } from '../hooks'; -import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useTrackPageview } from '../../../infra/public'; -import { getTitle } from '../lib/helper/get_title'; import { MonitorStatusDetails } from '../components/functional/monitor_status_details'; +import { PageHeader } from './page_header'; interface MonitorPageProps { - match: { params: { monitorId: string } }; - // this is the query function provided by Apollo's Client API - query: ( - options: QueryOptions - ) => Promise>; setBreadcrumbs: UMUpdateBreadcrumbs; } -export const MonitorPage = ({ query, setBreadcrumbs, match }: MonitorPageProps) => { +export const MonitorPage = ({ setBreadcrumbs }: MonitorPageProps) => { // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url - const monitorId = atob(match.params.monitorId); + let { monitorId } = useParams(); + monitorId = atob(monitorId || ''); + const [pingListPageCount, setPingListPageCount] = useState(10); - const { colors, refreshApp, setHeadingText } = useContext(UptimeSettingsContext); + const { colors, refreshApp } = useContext(UptimeSettingsContext); const [getUrlParams, updateUrlParams] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); const { dateRangeStart, dateRangeEnd, selectedPingStatus } = params; - useEffect(() => { - query({ - query: gql` - query MonitorPageTitle($monitorId: String!) { - monitorPageTitle: getMonitorPageTitle(monitorId: $monitorId) { - id - url - name - } - } - `, - variables: { monitorId }, - }).then((result: any) => { - const { name, url, id } = result.data.monitorPageTitle; - const heading: string = name || url || id; - document.title = getTitle(name); - setBreadcrumbs(getMonitorPageBreadcrumb(heading, stringifyUrlParams(params))); - if (setHeadingText) { - setHeadingText(heading); - } - }); - }, [monitorId, params, query, setBreadcrumbs, setHeadingText]); - const [selectedLocation, setSelectedLocation] = useState(undefined); const sharedVariables = { @@ -75,7 +46,7 @@ export const MonitorPage = ({ query, setBreadcrumbs, match }: MonitorPageProps) return ( - + ; - history: any; - location: { - pathname: string; - search: string; - }; setBreadcrumbs: UMUpdateBreadcrumbs; } @@ -52,8 +47,8 @@ const EuiFlexItemStyled = styled(EuiFlexItem)` } `; -export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) => { - const { colors, setHeadingText } = useContext(UptimeSettingsContext); +export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { + const { colors } = useContext(UptimeSettingsContext); const [getUrlParams, updateUrl] = useUrlParams(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); const { @@ -68,22 +63,9 @@ export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) useUptimeTelemetry(UptimePage.Overview); useIndexPattern(setIndexPattern); - useEffect(() => { - setBreadcrumbs(getOverviewPageBreadcrumbs()); - if (setHeadingText) { - setHeadingText( - i18n.translate('xpack.uptime.overviewPage.headerText', { - defaultMessage: 'Overview', - description: `The text that will be displayed in the app's heading when the Overview page loads.`, - }) - ); - } - }, [basePath, setBreadcrumbs, setHeadingText]); - useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); - const filterQueryString = search || ''; let error: any; let kueryString: string = ''; try { @@ -95,6 +77,7 @@ export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) kueryString = ''; } + const filterQueryString = search || ''; let filters: any | undefined; try { if (filterQueryString || urlFilters) { @@ -104,6 +87,15 @@ export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) const ast = esKuery.fromKueryExpression(combinedFilterString); const elasticsearchQuery = esKuery.toElasticsearchQuery(ast, staticIndexPattern); filters = JSON.stringify(elasticsearchQuery); + const searchDSL: string = filterQueryString + ? JSON.stringify( + esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(filterQueryString), + staticIndexPattern + ) + ) + : ''; + store.dispatch(setEsKueryString(searchDSL)); } } } catch (e) { @@ -121,20 +113,21 @@ export const OverviewPage = ({ basePath, autocomplete, setBreadcrumbs }: Props) return ( - + + { if (urlFilters !== filtersKuery) { updateUrl({ filters: filtersKuery, pagination: '' }); } }} - variables={sharedProps} /> {error && } diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx new file mode 100644 index 00000000000000..250dacb8914e76 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useEffect, useState, useContext } from 'react'; +import { connect } from 'react-redux'; +import { useRouteMatch, useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { UptimeDatePicker } from '../components/functional/uptime_date_picker'; +import { AppState } from '../state'; +import { selectSelectedMonitor } from '../state/selectors'; +import { getMonitorPageBreadcrumb, getOverviewPageBreadcrumbs } from '../breadcrumbs'; +import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; +import { UptimeSettingsContext } from '../contexts'; +import { getTitle } from '../lib/helper/get_title'; +import { UMUpdateBreadcrumbs } from '../lib/lib'; +import { MONITOR_ROUTE } from '../routes'; + +interface PageHeaderProps { + monitorStatus?: any; + setBreadcrumbs: UMUpdateBreadcrumbs; +} + +export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeaderProps) => { + const monitorPage = useRouteMatch({ + path: MONITOR_ROUTE, + }); + const { refreshApp } = useContext(UptimeSettingsContext); + + const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = useParams(); + + const headingText = i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }); + + const [headerText, setHeaderText] = useState(headingText); + + useEffect(() => { + if (monitorPage) { + setHeaderText(monitorStatus?.url?.full); + if (monitorStatus?.monitor) { + const { name, id } = monitorStatus.monitor; + document.title = getTitle(name || id); + } + } else { + document.title = getTitle(); + } + }, [monitorStatus, monitorPage, setHeaderText]); + + useEffect(() => { + if (monitorPage) { + if (headerText) { + setBreadcrumbs(getMonitorPageBreadcrumb(headerText, stringifyUrlParams(params))); + } + } else { + setBreadcrumbs(getOverviewPageBreadcrumbs()); + } + }, [headerText, setBreadcrumbs, params, monitorPage]); + + return ( + <> + + + +

{headerText}

+
+
+ + + +
+ + + ); +}; + +const mapStateToProps = (state: AppState) => ({ + monitorStatus: selectSelectedMonitor(state), +}); + +export const PageHeader = connect(mapStateToProps, null)(PageHeaderComponent); diff --git a/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts b/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts deleted file mode 100644 index a9b7e52c0f7938..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const filterBarQueryString = ` -query FilterBar($dateRangeStart: String!, $dateRangeEnd: String!) { - filterBar: getFilterBar(dateRangeStart: $dateRangeStart, dateRangeEnd: $dateRangeEnd) { - ids - locations - ports - schemes - urls - } -} -`; - -export const filterBarQuery = gql` - ${filterBarQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/queries/index.ts b/x-pack/legacy/plugins/uptime/public/queries/index.ts index b86522c03aba8c..02c9c7cb23403c 100644 --- a/x-pack/legacy/plugins/uptime/public/queries/index.ts +++ b/x-pack/legacy/plugins/uptime/public/queries/index.ts @@ -5,8 +5,5 @@ */ export { docCountQuery, docCountQueryString } from './doc_count_query'; -export { filterBarQuery, filterBarQueryString } from './filter_bar_query'; export { monitorChartsQuery, monitorChartsQueryString } from './monitor_charts_query'; -export { monitorPageTitleQuery } from './monitor_page_title_query'; -export { monitorStatusBarQuery, monitorStatusBarQueryString } from './monitor_status_bar_query'; export { pingsQuery, pingsQueryString } from './pings_query'; diff --git a/x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts b/x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts deleted file mode 100644 index 3b59ef80183f7e..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/monitor_page_title_query.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const monitorPageTitleQueryString = ` -query MonitorPageTitle($monitorId: String!) { - monitorPageTitle: getMonitorPageTitle(monitorId: $monitorId) { - id - url - name - } -}`; - -export const monitorPageTitleQuery = gql` - ${monitorPageTitleQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts b/x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts deleted file mode 100644 index 1f0eed6bb366ae..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/monitor_status_bar_query.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const monitorStatusBarQueryString = ` -query MonitorStatus($dateRangeStart: String!, $dateRangeEnd: String!, $monitorId: String, $location: String) { - monitorStatus: getLatestMonitors( - dateRangeStart: $dateRangeStart - dateRangeEnd: $dateRangeEnd - monitorId: $monitorId - location: $location - ) { - timestamp - monitor { - status - duration { - us - } - } - observer { - geo { - name - } - } - tls { - certificate_not_valid_after - } - url { - full - } - } -} -`; - -export const monitorStatusBarQuery = gql` - ${monitorStatusBarQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/routes.tsx b/x-pack/legacy/plugins/uptime/public/routes.tsx new file mode 100644 index 00000000000000..08d752f5b32abe --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/routes.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { MonitorPage, OverviewPage, NotFoundPage } from './pages'; +import { AutocompleteProviderRegister } from '../../../../../src/plugins/data/public'; +import { UMUpdateBreadcrumbs } from './lib/lib'; + +export const MONITOR_ROUTE = '/monitor/:monitorId/:location?'; +export const OVERVIEW_ROUTE = '/'; + +interface RouterProps { + autocomplete: Pick; + basePath: string; + setBreadcrumbs: UMUpdateBreadcrumbs; +} + +export const PageRouter: FC = ({ autocomplete, basePath, setBreadcrumbs }) => ( + + + + + + + + + +); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap new file mode 100644 index 00000000000000..6fe2c8eaa362d2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`overview filters action creators creates a fail action 1`] = ` +Object { + "payload": [Error: There was an error retrieving the overview filters], + "type": "FETCH_OVERVIEW_FILTERS_FAIL", +} +`; + +exports[`overview filters action creators creates a get action 1`] = ` +Object { + "payload": Object { + "dateRangeEnd": "now", + "dateRangeStart": "now-15m", + "locations": Array [ + "fairbanks", + "tokyo", + ], + "ports": Array [ + "80", + ], + "schemes": Array [ + "http", + "tcp", + ], + "search": "", + "statusFilter": "down", + "tags": Array [ + "api", + "dev", + ], + }, + "type": "FETCH_OVERVIEW_FILTERS", +} +`; + +exports[`overview filters action creators creates a success action 1`] = ` +Object { + "payload": Object { + "locations": Array [ + "fairbanks", + "tokyo", + "london", + ], + "ports": Array [ + 80, + 443, + ], + "schemes": Array [ + "http", + "tcp", + ], + "tags": Array [ + "api", + "dev", + "prod", + ], + }, + "type": "FETCH_OVERVIEW_FILTERS_SUCCESS", +} +`; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts new file mode 100644 index 00000000000000..4765e1327ce314 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + fetchOverviewFilters, + fetchOverviewFiltersSuccess, + fetchOverviewFiltersFail, +} from '../overview_filters'; + +describe('overview filters action creators', () => { + it('creates a get action', () => { + expect( + fetchOverviewFilters({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + statusFilter: 'down', + search: '', + locations: ['fairbanks', 'tokyo'], + ports: ['80'], + schemes: ['http', 'tcp'], + tags: ['api', 'dev'], + }) + ).toMatchSnapshot(); + }); + + it('creates a success action', () => { + expect( + fetchOverviewFiltersSuccess({ + locations: ['fairbanks', 'tokyo', 'london'], + ports: [80, 443], + schemes: ['http', 'tcp'], + tags: ['api', 'dev', 'prod'], + }) + ).toMatchSnapshot(); + }); + + it('creates a fail action', () => { + expect( + fetchOverviewFiltersFail(new Error('There was an error retrieving the overview filters')) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts index 6b896b07bb066c..9874da1839c2f3 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts @@ -4,5 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './overview_filters'; export * from './snapshot'; export * from './ui'; +export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.ts new file mode 100644 index 00000000000000..db103f6cb780e1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/monitor_status.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { createAction } from 'redux-actions'; +import { QueryParams } from './types'; + +export const getSelectedMonitor = createAction<{ monitorId: string }>('GET_SELECTED_MONITOR'); +export const getSelectedMonitorSuccess = createAction('GET_SELECTED_MONITOR_SUCCESS'); +export const getSelectedMonitorFail = createAction('GET_SELECTED_MONITOR_FAIL'); + +export const getMonitorStatus = createAction('GET_MONITOR_STATUS'); +export const getMonitorStatusSuccess = createAction('GET_MONITOR_STATUS_SUCCESS'); +export const getMonitorStatusFail = createAction('GET_MONITOR_STATUS_FAIL'); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts new file mode 100644 index 00000000000000..dbbd01e34b4d4e --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverviewFilters } from '../../../common/runtime_types'; + +export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS'; +export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL'; +export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS'; + +export interface GetOverviewFiltersPayload { + dateRangeStart: string; + dateRangeEnd: string; + locations: string[]; + ports: string[]; + schemes: string[]; + search?: string; + statusFilter?: string; + tags: string[]; +} + +interface GetOverviewFiltersFetchAction { + type: typeof FETCH_OVERVIEW_FILTERS; + payload: GetOverviewFiltersPayload; +} + +interface GetOverviewFiltersSuccessAction { + type: typeof FETCH_OVERVIEW_FILTERS_SUCCESS; + payload: OverviewFilters; +} + +interface GetOverviewFiltersFailAction { + type: typeof FETCH_OVERVIEW_FILTERS_FAIL; + payload: Error; +} + +export type OverviewFiltersAction = + | GetOverviewFiltersFetchAction + | GetOverviewFiltersSuccessAction + | GetOverviewFiltersFailAction; + +export const fetchOverviewFilters = ( + payload: GetOverviewFiltersPayload +): GetOverviewFiltersFetchAction => ({ + type: FETCH_OVERVIEW_FILTERS, + payload, +}); + +export const fetchOverviewFiltersFail = (error: Error): GetOverviewFiltersFailAction => ({ + type: FETCH_OVERVIEW_FILTERS_FAIL, + payload: error, +}); + +export const fetchOverviewFiltersSuccess = ( + filters: OverviewFilters +): GetOverviewFiltersSuccessAction => ({ + type: FETCH_OVERVIEW_FILTERS_SUCCESS, + payload: filters, +}); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts index fe87a6a5960eef..57d2b4ce38204f 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts @@ -5,6 +5,7 @@ */ import { Snapshot } from '../../../common/runtime_types'; + export const FETCH_SNAPSHOT_COUNT = 'FETCH_SNAPSHOT_COUNT'; export const FETCH_SNAPSHOT_COUNT_FAIL = 'FETCH_SNAPSHOT_COUNT_FAIL'; export const FETCH_SNAPSHOT_COUNT_SUCCESS = 'FETCH_SNAPSHOT_COUNT_SUCCESS'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/types.ts b/x-pack/legacy/plugins/uptime/public/state/actions/types.ts index 8419aaadc74bdd..dba70ed839ac57 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/types.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/types.ts @@ -5,10 +5,12 @@ */ export interface QueryParams { + monitorId: string; dateStart: string; dateEnd: string; filters?: string; statusFilter?: string; + location?: string; } export interface MonitorDetailsActionPayload { diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index fb38599495d845..d15d601737b2d6 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -16,6 +16,8 @@ export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); +export const setEsKueryString = createAction('SET ES KUERY STRING'); + export const toggleIntegrationsPopover = createAction( 'TOGGLE INTEGRATION POPOVER STATE' ); diff --git a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap index 53716681664c23..0d2392390c7e40 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,8 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`snapshot API throws when server response doesn't correspond to expected type 1`] = ` -[Error: Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/down: number -Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/mixed: number -Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/total: number -Invalid value undefined supplied to : { down: number, mixed: number, total: number, up: number }/up: number] +[Error: Invalid value undefined supplied to : { down: number, total: number, up: number }/down: number +Invalid value undefined supplied to : { down: number, total: number, up: number }/total: number +Invalid value undefined supplied to : { down: number, total: number, up: number }/up: number] `; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts index f5fdfb172bc58c..e9b1391a23e32a 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/__tests__/snapshot.test.ts @@ -14,7 +14,7 @@ describe('snapshot API', () => { fetchMock = jest.spyOn(window, 'fetch'); mockResponse = { ok: true, - json: () => new Promise(r => r({ up: 3, down: 12, mixed: 0, total: 15 })), + json: () => new Promise(r => r({ up: 3, down: 12, total: 15 })), }; }); @@ -34,7 +34,7 @@ describe('snapshot API', () => { expect(fetchMock).toHaveBeenCalledWith( '/api/uptime/snapshot/count?dateRangeStart=now-15m&dateRangeEnd=now&filters=monitor.id%3A%22auto-http-0X21EE76EAC459873F%22&statusFilter=up' ); - expect(resp).toEqual({ up: 3, down: 12, mixed: 0, total: 15 }); + expect(resp).toEqual({ up: 3, down: 12, total: 15 }); }); it(`throws when server response doesn't correspond to expected type`, async () => { diff --git a/x-pack/legacy/plugins/uptime/public/state/api/index.ts b/x-pack/legacy/plugins/uptime/public/state/api/index.ts index a4429868494f15..1d0cac5f878543 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/index.ts @@ -5,4 +5,6 @@ */ export * from './monitor'; +export * from './overview_filters'; export * from './snapshot'; +export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.ts new file mode 100644 index 00000000000000..936e864b75619a --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/api/monitor_status.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getApiPath } from '../../lib/helper'; +import { QueryParams } from '../actions/types'; +import { Ping } from '../../../common/graphql/types'; + +export interface APIParams { + basePath: string; + monitorId: string; +} + +export const fetchSelectedMonitor = async ({ basePath, monitorId }: APIParams): Promise => { + const url = getApiPath(`/api/uptime/monitor/selected`, basePath); + const params = { + monitorId, + }; + const urlParams = new URLSearchParams(params).toString(); + const response = await fetch(`${url}?${urlParams}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + return responseData; +}; + +export const fetchMonitorStatus = async ({ + basePath, + monitorId, + dateStart, + dateEnd, +}: QueryParams & APIParams): Promise => { + const url = getApiPath(`/api/uptime/monitor/status`, basePath); + const params = { + monitorId, + dateStart, + dateEnd, + }; + const urlParams = new URLSearchParams(params).toString(); + const response = await fetch(`${url}?${urlParams}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + return responseData; +}; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts new file mode 100644 index 00000000000000..c3ef62fa88dcf4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { isRight } from 'fp-ts/lib/Either'; +import { GetOverviewFiltersPayload } from '../actions/overview_filters'; +import { getApiPath, parameterizeValues } from '../../lib/helper'; +import { OverviewFiltersType } from '../../../common/runtime_types'; + +type ApiRequest = GetOverviewFiltersPayload & { + basePath: string; +}; + +export const fetchOverviewFilters = async ({ + basePath, + dateRangeStart, + dateRangeEnd, + search, + schemes, + locations, + ports, + tags, +}: ApiRequest) => { + const url = getApiPath(`/api/uptime/filters`, basePath); + + const params = new URLSearchParams({ + dateRangeStart, + dateRangeEnd, + }); + + if (search) { + params.append('search', search); + } + + parameterizeValues(params, { schemes, locations, ports, tags }); + + const response = await fetch(`${url}?${params.toString()}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + const decoded = OverviewFiltersType.decode(responseData); + + ThrowReporter.report(decoded); + if (isRight(decoded)) { + return decoded.right; + } + throw new Error('`getOverviewFilters` response did not correspond to expected type'); +}; diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts new file mode 100644 index 00000000000000..d293cdbe451b54 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { call, put, select } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { getBasePath } from '../selectors'; + +/** + * Factory function for a fetch effect. It expects three action creators, + * one to call for a fetch, one to call for success, and one to handle failures. + * @param fetch creates a fetch action + * @param success creates a success action + * @param fail creates a failure action + * @template T the action type expected by the fetch action + * @template R the type that the API request should return on success + * @template S tye type of the success action + * @template F the type of the failure action + */ +export function fetchEffectFactory( + fetch: (request: T) => Promise, + success: (response: R) => Action, + fail: (error: Error) => Action +) { + return function*(action: Action) { + try { + if (!action.payload) { + yield put(fail(new Error('Cannot fetch snapshot for undefined parameters.'))); + return; + } + const { + payload: { ...params }, + } = action; + const basePath = yield select(getBasePath); + const response = yield call(fetch, { ...params, basePath }); + yield put(success(response)); + } catch (error) { + yield put(fail(error)); + } + }; +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts index 4eb027d642974f..41dda145edb4e2 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts @@ -6,9 +6,13 @@ import { fork } from 'redux-saga/effects'; import { fetchMonitorDetailsEffect } from './monitor'; -import { fetchSnapshotCountSaga } from './snapshot'; +import { fetchOverviewFiltersEffect } from './overview_filters'; +import { fetchSnapshotCountEffect } from './snapshot'; +import { fetchMonitorStatusEffect } from './monitor_status'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); - yield fork(fetchSnapshotCountSaga); + yield fork(fetchSnapshotCountEffect); + yield fork(fetchOverviewFiltersEffect); + yield fork(fetchMonitorStatusEffect); } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts new file mode 100644 index 00000000000000..cab32092a14cd2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/monitor_status.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { call, put, takeLatest, select } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { + getSelectedMonitor, + getSelectedMonitorSuccess, + getSelectedMonitorFail, + getMonitorStatus, + getMonitorStatusSuccess, + getMonitorStatusFail, +} from '../actions/monitor_status'; +import { fetchSelectedMonitor, fetchMonitorStatus } from '../api'; +import { getBasePath } from '../selectors'; + +function* selectedMonitorEffect(action: Action) { + const { monitorId } = action.payload; + try { + const basePath = yield select(getBasePath); + const response = yield call(fetchSelectedMonitor, { + monitorId, + basePath, + }); + yield put({ type: getSelectedMonitorSuccess, payload: response }); + } catch (error) { + yield put({ type: getSelectedMonitorFail, payload: error.message }); + } +} + +function* monitorStatusEffect(action: Action) { + const { monitorId, dateStart, dateEnd } = action.payload; + try { + const basePath = yield select(getBasePath); + const response = yield call(fetchMonitorStatus, { + monitorId, + basePath, + dateStart, + dateEnd, + }); + yield put({ type: getMonitorStatusSuccess, payload: response }); + } catch (error) { + yield put({ type: getMonitorStatusFail, payload: error.message }); + } +} + +export function* fetchMonitorStatusEffect() { + yield takeLatest(getMonitorStatus, monitorStatusEffect); + yield takeLatest(getSelectedMonitor, selectedMonitorEffect); +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts new file mode 100644 index 00000000000000..92b578bafed2dd --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { takeLatest } from 'redux-saga/effects'; +import { + FETCH_OVERVIEW_FILTERS, + fetchOverviewFiltersFail, + fetchOverviewFiltersSuccess, +} from '../actions'; +import { fetchOverviewFilters } from '../api'; +import { fetchEffectFactory } from './fetch_effect'; + +export function* fetchOverviewFiltersEffect() { + yield takeLatest( + FETCH_OVERVIEW_FILTERS, + fetchEffectFactory(fetchOverviewFilters, fetchOverviewFiltersSuccess, fetchOverviewFiltersFail) + ); +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts index 23ac1016d22447..91df43dd9e8262 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts @@ -4,42 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { call, put, takeLatest, select } from 'redux-saga/effects'; -import { Action } from 'redux-actions'; +import { takeLatest } from 'redux-saga/effects'; import { FETCH_SNAPSHOT_COUNT, - GetSnapshotPayload, fetchSnapshotCountFail, fetchSnapshotCountSuccess, } from '../actions'; import { fetchSnapshotCount } from '../api'; -import { getBasePath } from '../selectors'; +import { fetchEffectFactory } from './fetch_effect'; -function* snapshotSaga(action: Action) { - try { - if (!action.payload) { - yield put( - fetchSnapshotCountFail(new Error('Cannot fetch snapshot for undefined parameters.')) - ); - return; - } - const { - payload: { dateRangeStart, dateRangeEnd, filters, statusFilter }, - } = action; - const basePath = yield select(getBasePath); - const response = yield call(fetchSnapshotCount, { - basePath, - dateRangeStart, - dateRangeEnd, - filters, - statusFilter, - }); - yield put(fetchSnapshotCountSuccess(response)); - } catch (error) { - yield put(fetchSnapshotCountFail(error)); - } -} - -export function* fetchSnapshotCountSaga() { - yield takeLatest(FETCH_SNAPSHOT_COUNT, snapshotSaga); +export function* fetchSnapshotCountEffect() { + yield takeLatest( + FETCH_SNAPSHOT_COUNT, + fetchEffectFactory(fetchSnapshotCount, fetchSnapshotCountSuccess, fetchSnapshotCountFail) + ); } diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap index d3a21ec9eece33..7a3c72f93d86fb 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,6 @@ exports[`snapshot reducer appends a current error to existing errors list 1`] = Object { "count": Object { "down": 0, - "mixed": 0, "total": 0, "up": 0, }, @@ -19,7 +18,6 @@ exports[`snapshot reducer changes the count when a snapshot fetch succeeds 1`] = Object { "count": Object { "down": 15, - "mixed": 0, "total": 25, "up": 10, }, @@ -32,7 +30,6 @@ exports[`snapshot reducer sets the state's status to loading during a fetch 1`] Object { "count": Object { "down": 0, - "mixed": 0, "total": 0, "up": 0, }, @@ -45,7 +42,6 @@ exports[`snapshot reducer updates existing state 1`] = ` Object { "count": Object { "down": 1, - "mixed": 0, "total": 4, "up": 3, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 155f7edbcbf33c..5d03c0058c3c1d 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -3,6 +3,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { "basePath": "", + "esKuery": "", "integrationsPopoverOpen": Object { "id": "popover-2", "open": true, @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { "basePath": "yyz", + "esKuery": "", "integrationsPopoverOpen": null, "lastRefresh": 125, } @@ -22,6 +24,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { "basePath": "abc", + "esKuery": "", "integrationsPopoverOpen": null, "lastRefresh": 125, } diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts index a4b317d5af1979..95c576e0fd72e5 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/snapshot.test.ts @@ -21,7 +21,7 @@ describe('snapshot reducer', () => { expect( snapshotReducer( { - count: { down: 1, mixed: 0, total: 4, up: 3 }, + count: { down: 1, total: 4, up: 3 }, errors: [], loading: false, }, @@ -47,7 +47,6 @@ describe('snapshot reducer', () => { payload: { up: 10, down: 15, - mixed: 0, total: 25, }, }; diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index ff9b7c3f9e8a47..417095b64ba2d7 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -15,6 +15,7 @@ describe('ui reducer', () => { uiReducer( { basePath: 'abc', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, @@ -32,6 +33,7 @@ describe('ui reducer', () => { uiReducer( { basePath: '', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, @@ -46,6 +48,7 @@ describe('ui reducer', () => { uiReducer( { basePath: 'abc', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts index b588feaf8a75a6..5f915d970e5431 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts @@ -6,12 +6,15 @@ import { combineReducers } from 'redux'; import { monitorReducer } from './monitor'; +import { overviewFiltersReducer } from './overview_filters'; import { snapshotReducer } from './snapshot'; import { uiReducer } from './ui'; +import { monitorStatusReducer } from './monitor_status'; export const rootReducer = combineReducers({ monitor: monitorReducer, + overviewFilters: overviewFiltersReducer, snapshot: snapshotReducer, - // @ts-ignore for now TODO: refactor to use redux-action ui: uiReducer, + monitorStatus: monitorStatusReducer, }); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts index 220ab0b2054622..aac8a90598d0c0 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor.ts @@ -19,10 +19,10 @@ import { MonitorLocations } from '../../../common/runtime_types'; type MonitorLocationsList = Map; export interface MonitorState { - monitorDetailsList: MonitorDetailsState[]; - monitorLocationsList: MonitorLocationsList; loading: boolean; errors: any[]; + monitorDetailsList: MonitorDetailsState[]; + monitorLocationsList: MonitorLocationsList; } const initialState: MonitorState = { diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.ts new file mode 100644 index 00000000000000..2688a0946dd610 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/monitor_status.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { handleActions, Action } from 'redux-actions'; +import { + getSelectedMonitor, + getSelectedMonitorSuccess, + getSelectedMonitorFail, + getMonitorStatus, + getMonitorStatusSuccess, + getMonitorStatusFail, +} from '../actions'; +import { Ping } from '../../../common/graphql/types'; +import { QueryParams } from '../actions/types'; + +export interface MonitorStatusState { + status: Ping | null; + monitor: Ping | null; + loading: boolean; +} + +const initialState: MonitorStatusState = { + status: null, + monitor: null, + loading: false, +}; + +type MonitorStatusPayload = QueryParams & Ping; + +export const monitorStatusReducer = handleActions( + { + [String(getSelectedMonitor)]: (state, action: Action) => ({ + ...state, + loading: true, + }), + + [String(getSelectedMonitorSuccess)]: (state, action: Action) => ({ + ...state, + loading: false, + monitor: { ...action.payload } as Ping, + }), + + [String(getSelectedMonitorFail)]: (state, action: Action) => ({ + ...state, + loading: false, + }), + + [String(getMonitorStatus)]: (state, action: Action) => ({ + ...state, + loading: true, + }), + + [String(getMonitorStatusSuccess)]: (state, action: Action) => ({ + ...state, + loading: false, + status: { ...action.payload } as Ping, + }), + + [String(getMonitorStatusFail)]: (state, action: Action) => ({ + ...state, + loading: false, + }), + }, + initialState +); diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts new file mode 100644 index 00000000000000..b219421f4f4dc1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverviewFilters } from '../../../common/runtime_types'; +import { + FETCH_OVERVIEW_FILTERS, + FETCH_OVERVIEW_FILTERS_FAIL, + FETCH_OVERVIEW_FILTERS_SUCCESS, + OverviewFiltersAction, +} from '../actions'; + +export interface OverviewFiltersState { + filters: OverviewFilters; + errors: Error[]; + loading: boolean; +} + +const initialState: OverviewFiltersState = { + filters: { + locations: [], + ports: [], + schemes: [], + tags: [], + }, + errors: [], + loading: false, +}; + +export function overviewFiltersReducer( + state = initialState, + action: OverviewFiltersAction +): OverviewFiltersState { + switch (action.type) { + case FETCH_OVERVIEW_FILTERS: + return { + ...state, + loading: true, + }; + case FETCH_OVERVIEW_FILTERS_SUCCESS: + return { + ...state, + filters: action.payload, + loading: false, + }; + case FETCH_OVERVIEW_FILTERS_FAIL: + return { + ...state, + errors: [...state.errors, action.payload], + }; + default: + return state; + } +} diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts index dd9449325f4fb9..2155d0e3a74e3c 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/snapshot.ts @@ -21,7 +21,6 @@ export interface SnapshotState { const initialState: SnapshotState = { count: { down: 0, - mixed: 0, total: 0, up: 0, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index b23245aa65fcaa..bb5bd22085ac68 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -9,6 +9,7 @@ import { PopoverState, toggleIntegrationsPopover, setBasePath, + setEsKueryString, triggerAppRefresh, UiPayload, } from '../actions/ui'; @@ -16,12 +17,14 @@ import { export interface UiState { integrationsPopoverOpen: PopoverState | null; basePath: string; + esKuery: string; lastRefresh: number; } const initialState: UiState = { integrationsPopoverOpen: null, basePath: '', + esKuery: '', lastRefresh: Date.now(), }; @@ -41,6 +44,11 @@ export const uiReducer = handleActions( ...state, lastRefresh: action.payload as number, }), + + [String(setEsKueryString)]: (state, action: Action) => ({ + ...state, + esKuery: action.payload as string, + }), }, initialState ); diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index 33b6400c56c608..38fb3edea4768a 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -9,6 +9,16 @@ import { AppState } from '../../../state'; describe('state selectors', () => { const state: AppState = { + overviewFilters: { + filters: { + locations: [], + ports: [], + schemes: [], + tags: [], + }, + errors: [], + loading: false, + }, monitor: { monitorDetailsList: [], monitorLocationsList: new Map(), @@ -19,7 +29,6 @@ describe('state selectors', () => { count: { up: 2, down: 0, - mixed: 0, total: 2, }, errors: [], @@ -27,9 +36,15 @@ describe('state selectors', () => { }, ui: { basePath: 'yyz', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, + monitorStatus: { + status: null, + monitor: null, + loading: false, + }, }; it('selects base path from state', () => { diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts index 08d1e8c8ca36d7..337e99f6ede16a 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/index.ts @@ -17,6 +17,14 @@ export const getMonitorDetails = (state: AppState, summary: any) => { return state.monitor.monitorDetailsList[summary.monitor_id]; }; -export const getMonitorLocations = (state: AppState, monitorId: string) => { +export const selectMonitorLocations = (state: AppState, monitorId: string) => { return state.monitor.monitorLocationsList?.get(monitorId); }; + +export const selectSelectedMonitor = (state: AppState) => { + return state.monitorStatus.monitor; +}; + +export const selectMonitorStatus = (state: AppState) => { + return state.monitorStatus.status; +}; diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index cecbfe375f5fed..25ff0e71770160 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -5,25 +5,25 @@ */ import DateMath from '@elastic/datemath'; -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPage } from '@elastic/eui'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { ApolloProvider } from 'react-apollo'; import { Provider as ReduxProvider } from 'react-redux'; -import { BrowserRouter as Router, Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom'; import { I18nStart, ChromeBreadcrumb, LegacyCoreStart } from 'src/core/public'; import { PluginsStart } from 'ui/new_platform/new_platform'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { UMGraphQLClient, UMUpdateBreadcrumbs, UMUpdateBadge } from './lib/lib'; -import { MonitorPage, OverviewPage, NotFoundPage } from './pages'; import { UptimeRefreshContext, UptimeSettingsContext, UMSettingsContextValues } from './contexts'; -import { UptimeDatePicker, CommonlyUsedRange } from './components/functional/uptime_date_picker'; +import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { useUrlParams } from './hooks'; import { getTitle } from './lib/helper/get_title'; import { store } from './state'; import { setBasePath, triggerAppRefresh } from './state/actions'; +import { PageRouter } from './routes'; export interface UptimeAppColors { danger: string; @@ -93,7 +93,6 @@ const Application = (props: UptimeAppProps) => { }; } const [lastRefresh, setLastRefresh] = useState(Date.now()); - const [headingText, setHeadingText] = useState(undefined); useEffect(() => { renderGlobalHelpControls(); @@ -146,7 +145,7 @@ const Application = (props: UptimeAppProps) => { isInfraAvailable, isLogsAvailable, refreshApp, - setHeadingText, + commonlyUsedRanges, }; }; @@ -166,49 +165,11 @@ const Application = (props: UptimeAppProps) => {
- - - -

{headingText}

-
-
- - - -
- - - ( - - )} - /> - ( - - )} - /> - - +
diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts index 8b685d8e08a2bf..897d67dde807ec 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts @@ -7,14 +7,9 @@ import { UMGqlRange } from '../../../common/domain_types'; import { UMResolver } from '../../../common/graphql/resolver_types'; import { - FilterBar, GetFilterBarQueryArgs, - GetLatestMonitorsQueryArgs, GetMonitorChartsDataQueryArgs, - GetMonitorPageTitleQueryArgs, MonitorChart, - MonitorPageTitle, - Ping, GetSnapshotHistogramQueryArgs, } from '../../../common/graphql/types'; import { UMServerLibs } from '../../lib/lib'; @@ -23,13 +18,6 @@ import { HistogramResult } from '../../../common/domain_types'; export type UMMonitorsResolver = UMResolver, any, UMGqlRange, UMContext>; -export type UMLatestMonitorsResolver = UMResolver< - Ping[] | Promise, - any, - GetLatestMonitorsQueryArgs, - UMContext ->; - export type UMGetMonitorChartsResolver = UMResolver< any | Promise, any, @@ -44,13 +32,6 @@ export type UMGetFilterBarResolver = UMResolver< UMContext >; -export type UMGetMontiorPageTitleResolver = UMResolver< - MonitorPageTitle | Promise | null, - any, - GetMonitorPageTitleQueryArgs, - UMContext ->; - export type UMGetSnapshotHistogram = UMResolver< HistogramResult | Promise, any, @@ -64,9 +45,6 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( Query: { getSnapshotHistogram: UMGetSnapshotHistogram; getMonitorChartsData: UMGetMonitorChartsResolver; - getLatestMonitors: UMLatestMonitorsResolver; - getFilterBar: UMGetFilterBarResolver; - getMonitorPageTitle: UMGetMontiorPageTitleResolver; }; } => ({ Query: { @@ -97,36 +75,5 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( location, }); }, - async getLatestMonitors( - _resolver, - { dateRangeStart, dateRangeEnd, monitorId, location }, - { APICaller } - ): Promise { - return await libs.pings.getLatestMonitorDocs({ - callES: APICaller, - dateRangeStart, - dateRangeEnd, - monitorId, - location, - }); - }, - async getFilterBar( - _resolver, - { dateRangeStart, dateRangeEnd }, - { APICaller } - ): Promise { - return await libs.monitors.getFilterBar({ - callES: APICaller, - dateRangeStart, - dateRangeEnd, - }); - }, - async getMonitorPageTitle( - _resolver: any, - { monitorId }, - { APICaller } - ): Promise { - return await libs.monitors.getMonitorPageTitle({ callES: APICaller, monitorId }); - }, }, }); diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts index f9b14c63e70bbc..8a86d97b4cd8e5 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts @@ -7,22 +7,6 @@ import gql from 'graphql-tag'; export const monitorsSchema = gql` - "The data used to enrich the filter bar." - type FilterBar { - "A series of monitor IDs in the heartbeat indices." - ids: [String!] - "The location values users have configured for the agents." - locations: [String!] - "The ports of the monitored endpoints." - ports: [Int!] - "The schemes used by the monitors." - schemes: [String!] - "The possible status values contained in the indices." - statuses: [String!] - "The list of URLs" - urls: [String!] - } - type HistogramDataPoint { upCount: Int downCount: Int @@ -114,12 +98,6 @@ export const monitorsSchema = gql` interval: UnsignedInteger! } - type MonitorPageTitle { - id: String! - url: String - name: String - } - extend type Query { getMonitors( dateRangeStart: String! @@ -142,21 +120,5 @@ export const monitorsSchema = gql` dateRangeEnd: String! location: String ): MonitorChart - - "Fetch the most recent event data for a monitor ID, date range, location." - getLatestMonitors( - "The lower limit of the date range." - dateRangeStart: String! - "The upper limit of the date range." - dateRangeEnd: String! - "Optional: a specific monitor ID filter." - monitorId: String - "Optional: a specific instance location filter." - location: String - ): [Ping!]! - - getFilterBar(dateRangeStart: String!, dateRangeEnd: String!): FilterBar - - getMonitorPageTitle(monitorId: String!): MonitorPageTitle } `; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap deleted file mode 100644 index 29c82ff455d369..00000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/__snapshots__/get_snapshot_helper.test.ts.snap +++ /dev/null @@ -1,10 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`get snapshot helper reduces check groups as expected 1`] = ` -Object { - "down": 1, - "mixed": 0, - "total": 3, - "up": 2, -} -`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts deleted file mode 100644 index 917e4a149de677..00000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/__tests__/get_snapshot_helper.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSnapshotCountHelper } from '../get_snapshot_helper'; -import { MonitorGroups } from '../search'; - -describe('get snapshot helper', () => { - let mockIterator: any; - beforeAll(() => { - mockIterator = jest.fn(); - const summaryTimestamp = new Date('2019-01-01'); - const firstResult: MonitorGroups = { - id: 'firstGroup', - groups: [ - { - monitorId: 'first-monitor', - location: 'us-east-1', - checkGroup: 'abc', - status: 'down', - summaryTimestamp, - }, - { - monitorId: 'first-monitor', - location: 'us-west-1', - checkGroup: 'abc', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'first-monitor', - location: 'amsterdam', - checkGroup: 'abc', - status: 'down', - summaryTimestamp, - }, - ], - }; - const secondResult: MonitorGroups = { - id: 'secondGroup', - groups: [ - { - monitorId: 'second-monitor', - location: 'us-east-1', - checkGroup: 'yyz', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'second-monitor', - location: 'us-west-1', - checkGroup: 'yyz', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'second-monitor', - location: 'amsterdam', - checkGroup: 'yyz', - status: 'up', - summaryTimestamp, - }, - ], - }; - const thirdResult: MonitorGroups = { - id: 'thirdGroup', - groups: [ - { - monitorId: 'third-monitor', - location: 'us-east-1', - checkGroup: 'dt', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'third-monitor', - location: 'us-west-1', - checkGroup: 'dt', - status: 'up', - summaryTimestamp, - }, - { - monitorId: 'third-monitor', - location: 'amsterdam', - checkGroup: 'dt', - status: 'up', - summaryTimestamp, - }, - ], - }; - - const mockNext = jest - .fn() - .mockReturnValueOnce(firstResult) - .mockReturnValueOnce(secondResult) - .mockReturnValueOnce(thirdResult) - .mockReturnValueOnce(null); - mockIterator.next = mockNext; - }); - - it('reduces check groups as expected', async () => { - expect(await getSnapshotCountHelper(mockIterator)).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts index d264da2e7ec0c6..eaaa8087e57cdd 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/elasticsearch_monitor_states_adapter.ts @@ -4,22 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UMMonitorStatesAdapter, CursorPagination } from './adapter_types'; +import { UMMonitorStatesAdapter } from './adapter_types'; import { INDEX_NAMES, CONTEXT_DEFAULTS } from '../../../../common/constants'; import { fetchPage } from './search'; import { MonitorGroupIterator } from './search/monitor_group_iterator'; -import { getSnapshotCountHelper } from './get_snapshot_helper'; - -export interface QueryContext { - count: (query: Record) => Promise; - search: (query: Record) => Promise; - dateRangeStart: string; - dateRangeEnd: string; - pagination: CursorPagination; - filterClause: any | null; - size: number; - statusFilter?: string; -} +import { Snapshot } from '../../../../common/runtime_types'; +import { QueryContext } from './search/query_context'; export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = { // Gets a page of monitor states. @@ -35,16 +25,15 @@ export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = { statusFilter = statusFilter === null ? undefined : statusFilter; const size = 10; - const queryContext: QueryContext = { - count: (query: Record): Promise => callES('count', query), - search: (query: Record): Promise => callES('search', query), + const queryContext = new QueryContext( + callES, dateRangeStart, dateRangeEnd, pagination, - filterClause: filters && filters !== '' ? JSON.parse(filters) : null, + filters && filters !== '' ? JSON.parse(filters) : null, size, - statusFilter, - }; + statusFilter + ); const page = await fetchPage(queryContext); @@ -55,18 +44,46 @@ export const elasticsearchMonitorStatesAdapter: UMMonitorStatesAdapter = { }; }, - getSnapshotCount: async ({ callES, dateRangeStart, dateRangeEnd, filters, statusFilter }) => { - const context: QueryContext = { - count: query => callES('count', query), - search: query => callES('search', query), + getSnapshotCount: async ({ + callES, + dateRangeStart, + dateRangeEnd, + filters, + statusFilter, + }): Promise => { + if (!(statusFilter === 'up' || statusFilter === 'down' || statusFilter === undefined)) { + throw new Error(`Invalid status filter value '${statusFilter}'`); + } + + const context = new QueryContext( + callES, dateRangeStart, dateRangeEnd, - pagination: CONTEXT_DEFAULTS.CURSOR_PAGINATION, - filterClause: filters && filters !== '' ? JSON.parse(filters) : null, - size: CONTEXT_DEFAULTS.MAX_MONITORS_FOR_SNAPSHOT_COUNT, - statusFilter, + CONTEXT_DEFAULTS.CURSOR_PAGINATION, + filters && filters !== '' ? JSON.parse(filters) : null, + CONTEXT_DEFAULTS.MAX_MONITORS_FOR_SNAPSHOT_COUNT, + statusFilter + ); + + // Calculate the total, up, and down counts. + const counts = await fastStatusCount(context); + + // Check if the last count was accurate, if not, we need to perform a slower count with the + // MonitorGroupsIterator. + if (!(await context.hasTimespan())) { + // Figure out whether 'up' or 'down' is more common. It's faster to count the lower cardinality + // one then use subtraction to figure out its opposite. + const [leastCommonStatus, mostCommonStatus]: Array<'up' | 'down'> = + counts.up > counts.down ? ['down', 'up'] : ['up', 'down']; + counts[leastCommonStatus] = await slowStatusCount(context, leastCommonStatus); + counts[mostCommonStatus] = counts.total - counts[leastCommonStatus]; + } + + return { + total: statusFilter ? counts[statusFilter] : counts.total, + up: statusFilter === 'down' ? 0 : counts.up, + down: statusFilter === 'up' ? 0 : counts.down, }; - return getSnapshotCountHelper(new MonitorGroupIterator(context)); }, statesIndexExists: async ({ callES }) => { @@ -92,3 +109,46 @@ const jsonifyPagination = (p: any): string | null => { return JSON.stringify(p); }; + +const fastStatusCount = async (context: QueryContext): Promise => { + const params = { + index: INDEX_NAMES.HEARTBEAT, + body: { + size: 0, + query: { bool: { filter: await context.dateAndCustomFilters() } }, + aggs: { + unique: { + // We set the precision threshold to 40k which is the max precision supported by cardinality + cardinality: { field: 'monitor.id', precision_threshold: 40000 }, + }, + down: { + filter: { range: { 'summary.down': { gt: 0 } } }, + aggs: { + unique: { cardinality: { field: 'monitor.id', precision_threshold: 40000 } }, + }, + }, + }, + }, + }; + + const statistics = await context.search(params); + const total = statistics.aggregations.unique.value; + const down = statistics.aggregations.down.unique.value; + + return { + total, + down, + up: total - down, + }; +}; + +const slowStatusCount = async (context: QueryContext, status: string): Promise => { + const downContext = context.clone(); + downContext.statusFilter = status; + const iterator = new MonitorGroupIterator(downContext); + let count = 0; + while (await iterator.next()) { + count++; + } + return count; +}; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.ts deleted file mode 100644 index 8bd21b77406df1..00000000000000 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/get_snapshot_helper.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { MonitorGroups, MonitorGroupIterator } from './search'; -import { Snapshot } from '../../../../common/runtime_types'; - -const reduceItemsToCounts = (items: MonitorGroups[]) => { - let down = 0; - let up = 0; - items.forEach(item => { - if (item.groups.some(group => group.status === 'down')) { - down++; - } else { - up++; - } - }); - return { - down, - mixed: 0, - total: down + up, - up, - }; -}; - -export const getSnapshotCountHelper = async (iterator: MonitorGroupIterator): Promise => { - const items: MonitorGroups[] = []; - let res: MonitorGroups | null; - // query the index to find the most recent check group for each monitor/location - do { - res = await iterator.next(); - if (res) { - items.push(res); - } - } while (res !== null); - - return reduceItemsToCounts(items); -}; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts index d571a5a902539d..0bbdaa87a5e661 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/fetch_page.test.ts @@ -11,7 +11,7 @@ import { MonitorGroupsFetcher, MonitorGroupsPage, } from '../fetch_page'; -import { QueryContext } from '../../elasticsearch_monitor_states_adapter'; +import { QueryContext } from '../query_context'; import { MonitorSummary } from '../../../../../../common/graphql/types'; import { nextPagination, prevPagination, simpleQueryContext } from './test_helpers'; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts index b8df3b635dc6ba..0ce5e75195475f 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/monitor_group_iterator.test.ts @@ -11,8 +11,8 @@ import { MonitorGroupIterator, } from '../monitor_group_iterator'; import { simpleQueryContext } from './test_helpers'; -import { QueryContext } from '../../elasticsearch_monitor_states_adapter'; import { MonitorGroups } from '../fetch_page'; +import { QueryContext } from '../query_context'; describe('iteration', () => { let iterator: MonitorGroupIterator | null = null; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts index d6fe5f82e735d8..bb3f3da3e289da 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/__tests__/test_helpers.ts @@ -6,7 +6,7 @@ import { CursorPagination } from '../../adapter_types'; import { CursorDirection, SortOrder } from '../../../../../../common/graphql/types'; -import { QueryContext } from '../../elasticsearch_monitor_states_adapter'; +import { QueryContext } from '../query_context'; export const prevPagination = (key: any): CursorPagination => { return { @@ -23,14 +23,5 @@ export const nextPagination = (key: any): CursorPagination => { }; }; export const simpleQueryContext = (): QueryContext => { - return { - count: _query => new Promise(r => ({})), - search: _query => new Promise(r => ({})), - dateRangeEnd: '', - dateRangeStart: '', - filterClause: undefined, - pagination: nextPagination('something'), - size: 0, - statusFilter: '', - }; + return new QueryContext(undefined, '', '', nextPagination('something'), undefined, 0, ''); }; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts index 093e105635c2c9..b64015424ff401 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/enrich_monitor_groups.ts @@ -5,7 +5,7 @@ */ import { get, sortBy } from 'lodash'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { getHistogramIntervalFormatted } from '../../../helper'; import { INDEX_NAMES, STATES } from '../../../../../common/constants'; import { @@ -77,19 +77,19 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } String agentIdIP = agentId + "-" + (ip == null ? "" : ip.toString()); def ts = doc["@timestamp"][0].toInstant().toEpochMilli(); - + def lastCheck = state.checksByAgentIdIP[agentId]; Instant lastTs = lastCheck != null ? lastCheck["@timestamp"] : null; if (lastTs != null && lastTs > ts) { return; } - + curCheck.put("@timestamp", ts); - + Map agent = new HashMap(); agent.id = agentId; curCheck.put("agent", agent); - + if (state.globals.url == null) { Map url = new HashMap(); Collection fields = ["full", "original", "scheme", "username", "password", "domain", "port", "path", "query", "fragment"]; @@ -102,7 +102,7 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } state.globals.url = url; } - + Map monitor = new HashMap(); monitor.status = doc["monitor.status"][0]; monitor.ip = ip; @@ -113,7 +113,7 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } } curCheck.monitor = monitor; - + if (curCheck.observer == null) { curCheck.observer = new HashMap(); } @@ -144,14 +144,14 @@ export const enrichMonitorGroups: MonitorEnricher = async ( if (!doc["tls.certificate_not_valid_before"].isEmpty()) { curCheck.tls.certificate_not_valid_before = doc["tls.certificate_not_valid_before"][0]; } - + state.checksByAgentIdIP[agentIdIP] = curCheck; `, combine_script: 'return state;', reduce_script: ` // The final document Map result = new HashMap(); - + Map checks = new HashMap(); Instant maxTs = Instant.ofEpochMilli(0); Collection ips = new HashSet(); @@ -159,7 +159,7 @@ export const enrichMonitorGroups: MonitorEnricher = async ( Collection podUids = new HashSet(); Collection containerIds = new HashSet(); Collection tls = new HashSet(); - String name = null; + String name = null; for (state in states) { result.putAll(state.globals); for (entry in state.checksByAgentIdIP.entrySet()) { @@ -167,18 +167,18 @@ export const enrichMonitorGroups: MonitorEnricher = async ( def check = entry.getValue(); def lastBestCheck = checks.get(agentIdIP); def checkTs = Instant.ofEpochMilli(check.get("@timestamp")); - + if (maxTs.isBefore(checkTs)) { maxTs = checkTs} - + if (lastBestCheck == null || lastBestCheck.get("@timestamp") < checkTs) { check["@timestamp"] = check["@timestamp"]; checks[agentIdIP] = check } - + if (check.monitor.name != null && check.monitor.name != "") { name = check.monitor.name; } - + ips.add(check.monitor.ip); if (check.observer != null && check.observer.geo != null && check.observer.geo.name != null) { geoNames.add(check.observer.geo.name); @@ -194,45 +194,45 @@ export const enrichMonitorGroups: MonitorEnricher = async ( } } } - + // We just use the values so we can store these as nested docs result.checks = checks.values(); result.put("@timestamp", maxTs); - - + + Map summary = new HashMap(); summary.up = checks.entrySet().stream().filter(c -> c.getValue().monitor.status == "up").count(); summary.down = checks.size() - summary.up; result.summary = summary; - + Map monitor = new HashMap(); monitor.ip = ips; monitor.name = name; - monitor.status = summary.down > 0 ? (summary.up > 0 ? "mixed": "down") : "up"; + monitor.status = summary.down > 0 ? "down" : "up"; result.monitor = monitor; - + Map observer = new HashMap(); Map geo = new HashMap(); observer.geo = geo; geo.name = geoNames; result.observer = observer; - + if (!podUids.isEmpty()) { result.kubernetes = new HashMap(); result.kubernetes.pod = new HashMap(); result.kubernetes.pod.uid = podUids; } - + if (!containerIds.isEmpty()) { result.container = new HashMap(); result.container.id = containerIds; } - + if (!tls.isEmpty()) { result.tls = new HashMap(); result.tls = tls; } - + return result; `, }, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts index e395df0d1d08d5..77676ac9a63731 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_chunk.ts @@ -6,8 +6,8 @@ import { refinePotentialMatches } from './refine_potential_matches'; import { findPotentialMatches } from './find_potential_matches'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; import { ChunkFetcher, ChunkResult } from './monitor_group_iterator'; +import { QueryContext } from './query_context'; /** * Fetches a single 'chunk' of data with a single query, then uses a secondary query to filter out erroneous matches. diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts index 085c11f78b8f59..046bdc8a8d07d8 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/fetch_page.ts @@ -6,7 +6,7 @@ import { flatten } from 'lodash'; import { CursorPagination } from '../adapter_types'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { QUERY } from '../../../../../common/constants'; import { CursorDirection, MonitorSummary, SortOrder } from '../../../../../common/graphql/types'; import { enrichMonitorGroups } from './enrich_monitor_groups'; @@ -51,6 +51,7 @@ const fetchPageMonitorGroups: MonitorGroupsFetcher = async ( size: number ): Promise => { const monitorGroups: MonitorGroups[] = []; + const iterator = new MonitorGroupIterator(queryContext); let paginationBefore: CursorPagination | null = null; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts index 8f5e26b75f56cd..e34bc6ab805c06 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/find_potential_matches.ts @@ -5,10 +5,9 @@ */ import { get, set } from 'lodash'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; import { CursorDirection } from '../../../../../common/graphql/types'; import { INDEX_NAMES } from '../../../../../common/constants'; -import { makeDateRangeFilter } from '../../../helper/make_date_rate_filter'; +import { QueryContext } from './query_context'; // This is the first phase of the query. In it, we find the most recent check groups that matched the given query. // Note that these check groups may not be the most recent groups for the matching monitor ID! We'll filter those @@ -55,7 +54,7 @@ export const findPotentialMatches = async ( }; const query = async (queryContext: QueryContext, searchAfter: any, size: number) => { - const body = queryBody(queryContext, searchAfter, size); + const body = await queryBody(queryContext, searchAfter, size); const params = { index: INDEX_NAMES.HEARTBEAT, @@ -65,15 +64,11 @@ const query = async (queryContext: QueryContext, searchAfter: any, size: number) return await queryContext.search(params); }; -const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) => { +const queryBody = async (queryContext: QueryContext, searchAfter: any, size: number) => { const compositeOrder = cursorDirectionToOrder(queryContext.pagination.cursorDirection); - const filters: any[] = [ - makeDateRangeFilter(queryContext.dateRangeStart, queryContext.dateRangeEnd), - ]; - if (queryContext.filterClause) { - filters.push(queryContext.filterClause); - } + const filters = await queryContext.dateAndCustomFilters(); + if (queryContext.statusFilter) { filters.push({ match: { 'monitor.status': queryContext.statusFilter } }); } @@ -82,6 +77,11 @@ const queryBody = (queryContext: QueryContext, searchAfter: any, size: number) = size: 0, query: { bool: { filter: filters } }, aggs: { + has_timespan: { + filter: { + exists: { field: 'monitor.timespan' }, + }, + }, monitors: { composite: { size, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts index 1de2dbb0e364d6..27c16863a37ab3 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/monitor_group_iterator.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { CursorPagination } from '../adapter_types'; import { fetchChunk } from './fetch_chunk'; import { CursorDirection } from '../../../../../common/graphql/types'; @@ -155,7 +155,7 @@ export class MonitorGroupIterator { // Returns a copy of this fetcher that goes backwards from the current position reverse(): MonitorGroupIterator | null { - const reverseContext = Object.assign({}, this.queryContext); + const reverseContext = this.queryContext.clone(); const current = this.getCurrent(); reverseContext.pagination = { diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts new file mode 100644 index 00000000000000..03e228952f0e79 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import DateMath from '@elastic/datemath'; +import { APICaller } from 'kibana/server'; +import { CursorPagination } from '../adapter_types'; +import { INDEX_NAMES } from '../../../../../common/constants'; + +export class QueryContext { + callES: APICaller; + dateRangeStart: string; + dateRangeEnd: string; + pagination: CursorPagination; + filterClause: any | null; + size: number; + statusFilter?: string; + hasTimespanCache?: boolean; + + constructor( + database: any, + dateRangeStart: string, + dateRangeEnd: string, + pagination: CursorPagination, + filterClause: any | null, + size: number, + statusFilter?: string + ) { + this.callES = database; + this.dateRangeStart = dateRangeStart; + this.dateRangeEnd = dateRangeEnd; + this.pagination = pagination; + this.filterClause = filterClause; + this.size = size; + this.statusFilter = statusFilter; + } + + async search(params: any): Promise { + params.index = INDEX_NAMES.HEARTBEAT; + return this.callES('search', params); + } + + async count(params: any): Promise { + params.index = INDEX_NAMES.HEARTBEAT; + return this.callES('count', params); + } + + async dateAndCustomFilters(): Promise { + const clauses = [await this.dateRangeFilter()]; + if (this.filterClause) { + clauses.push(this.filterClause); + } + return clauses; + } + + async dateRangeFilter(forceNoTimespan?: boolean): Promise { + const timestampClause = { + range: { '@timestamp': { gte: this.dateRangeStart, lte: this.dateRangeEnd } }, + }; + + if (forceNoTimespan === true || !(await this.hasTimespan())) { + return timestampClause; + } + + // @ts-ignore + const tsStart = DateMath.parse(this.dateRangeEnd).subtract(10, 'seconds'); + const tsEnd = DateMath.parse(this.dateRangeEnd)!; + + return { + bool: { + filter: [ + timestampClause, + { + bool: { + should: [ + { + range: { + 'monitor.timespan': { + gte: tsStart.toISOString(), + lte: tsEnd.toISOString(), + }, + }, + }, + { + bool: { + must_not: { exists: { field: 'monitor.timespan' } }, + }, + }, + ], + }, + }, + ], + }, + }; + } + + async hasTimespan(): Promise { + if (this.hasTimespanCache) { + return this.hasTimespanCache; + } + + this.hasTimespanCache = + ( + await this.count({ + body: { + query: { + bool: { + filter: [ + await this.dateRangeFilter(true), + { exists: { field: 'monitor.timespan' } }, + ], + }, + }, + }, + terminate_after: 1, + }) + ).count > 0; + + return this.hasTimespanCache; + } + + clone(): QueryContext { + return new QueryContext( + this.callES, + this.dateRangeStart, + this.dateRangeEnd, + this.pagination, + this.filterClause, + this.size, + this.statusFilter + ); + } +} diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts index b0060cbee17bb7..f8347d07375218 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/refine_potential_matches.ts @@ -5,10 +5,9 @@ */ import { INDEX_NAMES } from '../../../../../common/constants'; -import { QueryContext } from '../elasticsearch_monitor_states_adapter'; +import { QueryContext } from './query_context'; import { CursorDirection } from '../../../../../common/graphql/types'; import { MonitorGroups, MonitorLocCheckGroup } from './fetch_page'; -import { makeDateRangeFilter } from '../../../helper/make_date_rate_filter'; /** * Determines whether the provided check groups are the latest complete check groups for their associated monitor ID's. @@ -103,7 +102,7 @@ export const mostRecentCheckGroups = async ( query: { bool: { filter: [ - makeDateRangeFilter(queryContext.dateRangeStart, queryContext.dateRangeEnd), + await queryContext.dateRangeFilter(), { terms: { 'monitor.id': potentialMatchMonitorIDs } }, // only match summary docs because we only want the latest *complete* check group. { exists: { field: 'summary' } }, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap new file mode 100644 index 00000000000000..2f6d6e06f93e12 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractFilterAggsResults extracts the bucket values of the expected filter fields 1`] = ` +Object { + "locations": Array [ + "us-east-2", + "fairbanks", + ], + "ports": Array [ + 12349, + 80, + 5601, + 8200, + 9200, + 9292, + ], + "schemes": Array [ + "http", + "tcp", + "icmp", + ], + "tags": Array [ + "api", + "dev", + ], +} +`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap new file mode 100644 index 00000000000000..0f7abf5050bca2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateFilterAggs generates expected aggregations object 1`] = ` +Object { + "locations": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "observer.geo.name", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, + "ports": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "url.port", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, + "schemes": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "monitor.type", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + ], + }, + }, + }, + "tags": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "tags", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, +} +`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts new file mode 100644 index 00000000000000..2075b3a8fbe0f6 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { combineRangeWithFilters } from '../elasticsearch_monitors_adapter'; + +describe('combineRangeWithFilters', () => { + it('combines filters that have no filter clause', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { should: [{ match: { 'url.port': 80 } }], minimum_should_match: 1 }, + }) + ).toEqual({ + bool: { + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + filter: [ + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + }, + }); + }); + + it('combines query with filter object', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { + filter: { term: { field: 'monitor.id' } }, + should: [{ match: { 'url.port': 80 } }], + minimum_should_match: 1, + }, + }) + ).toEqual({ + bool: { + filter: [ + { + field: 'monitor.id', + }, + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('combines query with filter list', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { + filter: [{ field: 'monitor.id' }], + should: [{ match: { 'url.port': 80 } }], + minimum_should_match: 1, + }, + }) + ).toEqual({ + bool: { + filter: [ + { + field: 'monitor.id', + }, + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts new file mode 100644 index 00000000000000..954cffd4c9522d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extractFilterAggsResults } from '../elasticsearch_monitors_adapter'; + +describe('extractFilterAggsResults', () => { + it('extracts the bucket values of the expected filter fields', () => { + expect( + extractFilterAggsResults( + { + locations: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'us-east-2', doc_count: 4050 }, + { key: 'fairbanks', doc_count: 4048 }, + ], + }, + }, + schemes: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'http', doc_count: 5055 }, + { key: 'tcp', doc_count: 2685 }, + { key: 'icmp', doc_count: 358 }, + ], + }, + }, + ports: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 12349, doc_count: 3571 }, + { key: 80, doc_count: 2985 }, + { key: 5601, doc_count: 358 }, + { key: 8200, doc_count: 358 }, + { key: 9200, doc_count: 358 }, + { key: 9292, doc_count: 110 }, + ], + }, + }, + tags: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'api', doc_count: 8098 }, + { key: 'dev', doc_count: 8098 }, + ], + }, + }, + }, + ['locations', 'ports', 'schemes', 'tags'] + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts new file mode 100644 index 00000000000000..4e285ec25a492f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generateFilterAggs } from '../generate_filter_aggs'; + +describe('generateFilterAggs', () => { + it('generates expected aggregations object', () => { + expect( + generateFilterAggs( + [ + { aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' }, + { aggName: 'ports', filterName: 'ports', field: 'url.port' }, + { aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' }, + { aggName: 'tags', filterName: 'tags', field: 'tags' }, + ], + { + locations: ['fairbanks', 'us-east-2'], + ports: ['80', '5601'], + tags: ['api'], + schemes: ['http', 'tcp'], + } + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts index 99d346e5d666eb..8523d9c75f51fb 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MonitorChart, MonitorPageTitle } from '../../../../common/graphql/types'; +import { MonitorChart } from '../../../../common/graphql/types'; import { UMElasticsearchQueryFn } from '../framework'; -import { MonitorDetails, MonitorLocations } from '../../../../common/runtime_types'; +import { + MonitorDetails, + MonitorLocations, + OverviewFilters, +} from '../../../../common/runtime_types'; export interface GetMonitorChartsDataParams { /** @member monitorId ID value for the selected monitor */ @@ -20,9 +24,15 @@ export interface GetMonitorChartsDataParams { } export interface GetFilterBarParams { + /** @param dateRangeStart timestamp bounds */ dateRangeStart: string; /** @member dateRangeEnd timestamp bounds */ dateRangeEnd: string; + /** @member search this value should correspond to Elasticsearch DSL + * generated from KQL text the user provided. + */ + search?: Record; + filterOptions: Record; } export interface GetMonitorDetailsParams { @@ -31,11 +41,6 @@ export interface GetMonitorDetailsParams { dateEnd: string; } -export interface GetMonitorPageTitleParams { - /** @member monitorId the ID to query */ - monitorId: string; -} - /** * Fetch data for the monitor page title. */ @@ -53,11 +58,13 @@ export interface UMMonitorsAdapter { * Fetches data used to populate monitor charts */ getMonitorChartsData: UMElasticsearchQueryFn; - getFilterBar: UMElasticsearchQueryFn; + /** - * Fetch data for the monitor page title. + * Fetch options for the filter bar. */ - getMonitorPageTitle: UMElasticsearchQueryFn<{ monitorId: string }, MonitorPageTitle | null>; + getFilterBar: UMElasticsearchQueryFn; + getMonitorDetails: UMElasticsearchQueryFn; + getMonitorLocations: UMElasticsearchQueryFn; } diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index 5f4c2e45d97597..37a9e032cd442d 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts @@ -6,10 +6,56 @@ import { get } from 'lodash'; import { INDEX_NAMES } from '../../../../common/constants'; -import { MonitorChart, Ping, LocationDurationLine } from '../../../../common/graphql/types'; +import { MonitorChart, LocationDurationLine } from '../../../../common/graphql/types'; import { getHistogramIntervalFormatted } from '../../helper'; import { MonitorError, MonitorLocation } from '../../../../common/runtime_types'; import { UMMonitorsAdapter } from './adapter_types'; +import { generateFilterAggs } from './generate_filter_aggs'; +import { OverviewFilters } from '../../../../common/runtime_types'; + +export const combineRangeWithFilters = ( + dateRangeStart: string, + dateRangeEnd: string, + filters?: Record +) => { + const range = { + range: { + '@timestamp': { + gte: dateRangeStart, + lte: dateRangeEnd, + }, + }, + }; + if (!filters) return range; + const clientFiltersList = Array.isArray(filters?.bool?.filter ?? {}) + ? // i.e. {"bool":{"filter":{ ...some nested filter objects }}} + filters.bool.filter + : // i.e. {"bool":{"filter":[ ...some listed filter objects ]}} + Object.keys(filters?.bool?.filter ?? {}).map(key => ({ + ...filters?.bool?.filter?.[key], + })); + filters.bool.filter = [...clientFiltersList, range]; + return filters; +}; + +type SupportedFields = 'locations' | 'ports' | 'schemes' | 'tags'; + +export const extractFilterAggsResults = ( + responseAggregations: Record, + keys: SupportedFields[] +): OverviewFilters => { + const values: OverviewFilters = { + locations: [], + ports: [], + schemes: [], + tags: [], + }; + keys.forEach(key => { + const buckets = responseAggregations[key]?.term?.buckets ?? []; + values[key] = buckets.map((item: { key: string | number }) => item.key); + }); + return values; +}; const formatStatusBuckets = (time: any, buckets: any, docCount: any) => { let up = null; @@ -160,75 +206,30 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { return monitorChartsData; }, - getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd }) => { - const fields: { [key: string]: string } = { - ids: 'monitor.id', - schemes: 'monitor.type', - urls: 'url.full', - ports: 'url.port', - locations: 'observer.geo.name', - }; + getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd, search, filterOptions }) => { + const aggs = generateFilterAggs( + [ + { aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' }, + { aggName: 'ports', filterName: 'ports', field: 'url.port' }, + { aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' }, + { aggName: 'tags', filterName: 'tags', field: 'tags' }, + ], + filterOptions + ); + const filters = combineRangeWithFilters(dateRangeStart, dateRangeEnd, search); const params = { index: INDEX_NAMES.HEARTBEAT, body: { size: 0, query: { - range: { - '@timestamp': { - gte: dateRangeStart, - lte: dateRangeEnd, - }, - }, + ...filters, }, - aggs: Object.values(fields).reduce((acc: { [key: string]: any }, field) => { - acc[field] = { terms: { field, size: 20 } }; - return acc; - }, {}), + aggs, }, }; - const { aggregations } = await callES('search', params); - - return Object.keys(fields).reduce((acc: { [key: string]: any[] }, field) => { - const bucketName = fields[field]; - acc[field] = aggregations[bucketName].buckets.map((b: { key: string | number }) => b.key); - return acc; - }, {}); - }, - getMonitorPageTitle: async ({ callES, monitorId }) => { - const params = { - index: INDEX_NAMES.HEARTBEAT, - body: { - query: { - bool: { - filter: { - term: { - 'monitor.id': monitorId, - }, - }, - }, - }, - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - size: 1, - }, - }; - - const result = await callES('search', params); - const pageTitle: Ping | null = get(result, 'hits.hits[0]._source', null); - if (pageTitle === null) { - return null; - } - return { - id: get(pageTitle, 'monitor.id', null) || monitorId, - url: get(pageTitle, 'url.full', null), - name: get(pageTitle, 'monitor.name', null), - }; + const { aggregations } = await callES('search', params); + return extractFilterAggsResults(aggregations, ['tags', 'locations', 'ports', 'schemes']); }, getMonitorDetails: async ({ callES, monitorId, dateStart, dateEnd }) => { @@ -289,11 +290,6 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { }; }, - /** - * Fetch data for the monitor page title. - * @param request Kibana server request - * - */ getMonitorLocations: async ({ callES, monitorId, dateStart, dateEnd }) => { const params = { index: INDEX_NAMES.HEARTBEAT, diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts new file mode 100644 index 00000000000000..26d412e33c8687 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface AggDefinition { + aggName: string; + filterName: string; + field: string; +} + +export const FIELD_MAPPINGS: Record = { + schemes: 'monitor.type', + ports: 'url.port', + locations: 'observer.geo.name', + tags: 'tags', +}; + +const getFilterAggConditions = (filterTerms: Record, except: string) => { + const filters: any[] = []; + + Object.keys(filterTerms).forEach((key: string) => { + if (key === except && FIELD_MAPPINGS[key]) return; + filters.push( + ...filterTerms[key].map(value => ({ + term: { + [FIELD_MAPPINGS[key]]: value, + }, + })) + ); + }); + + return filters; +}; + +export const generateFilterAggs = ( + aggDefinitions: AggDefinition[], + filterOptions: Record +) => + aggDefinitions + .map(({ aggName, filterName, field }) => ({ + [aggName]: { + filter: { + bool: { + should: [...getFilterAggConditions(filterOptions, filterName)], + }, + }, + aggs: { + term: { + terms: { + field, + }, + }, + }, + }, + })) + .reduce((parent: Record, agg: any) => ({ ...parent, ...agg }), {}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts index bd1c182e938a3f..e1e39ac9b26377 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/__tests__/elasticsearch_pings_adapter.test.ts @@ -411,7 +411,7 @@ describe('ElasticsearchPingsAdapter class', () => { }); }); - describe('getLatestMonitorDocs', () => { + describe('getLatestMonitorStatus', () => { let expectedGetLatestSearchParams: any; beforeEach(() => { expectedGetLatestSearchParams = { @@ -429,7 +429,7 @@ describe('ElasticsearchPingsAdapter class', () => { }, }, { - term: { 'monitor.id': 'testmonitor' }, + term: { 'monitor.id': 'testMonitor' }, }, ], }, @@ -467,7 +467,7 @@ describe('ElasticsearchPingsAdapter class', () => { _source: { '@timestamp': 123456, monitor: { - id: 'testmonitor', + id: 'testMonitor', }, }, }, @@ -483,17 +483,16 @@ describe('ElasticsearchPingsAdapter class', () => { it('returns data in expected shape', async () => { const mockEsClient = jest.fn(async (_request: any, _params: any) => mockEsSearchResult); - const result = await adapter.getLatestMonitorDocs({ + const result = await adapter.getLatestMonitorStatus({ callES: mockEsClient, - dateRangeStart: 'now-1h', - dateRangeEnd: 'now', - monitorId: 'testmonitor', + dateStart: 'now-1h', + dateEnd: 'now', + monitorId: 'testMonitor', }); - expect(result).toHaveLength(1); - expect(result[0].timestamp).toBe(123456); - expect(result[0].monitor).not.toBeFalsy(); + expect(result.timestamp).toBe(123456); + expect(result.monitor).not.toBeFalsy(); // @ts-ignore monitor will be defined - expect(result[0].monitor.id).toBe('testmonitor'); + expect(result.monitor.id).toBe('testMonitor'); expect(mockEsClient).toHaveBeenCalledWith('search', expectedGetLatestSearchParams); }); }); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts index 81df1c7c0f6312..8b2a49c0c9ffec 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/adapter_types.ts @@ -33,16 +33,13 @@ export interface GetAllParams { export interface GetLatestMonitorDocsParams { /** @member dateRangeStart timestamp bounds */ - dateRangeStart: string; + dateStart?: string; /** @member dateRangeEnd timestamp bounds */ - dateRangeEnd: string; + dateEnd?: string; /** @member monitorId optional limit to monitorId */ monitorId?: string | null; - - /** @member location optional location value for use in filtering*/ - location?: string | null; } export interface GetPingHistogramParams { @@ -64,7 +61,10 @@ export interface GetPingHistogramParams { export interface UMPingsAdapter { getAll: UMElasticsearchQueryFn; - getLatestMonitorDocs: UMElasticsearchQueryFn; + // Get the monitor meta info regardless of timestamp + getMonitor: UMElasticsearchQueryFn; + + getLatestMonitorStatus: UMElasticsearchQueryFn; getPingHistogram: UMElasticsearchQueryFn; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts index d05cb5758ca364..adabffcb1ea4a8 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/pings/elasticsearch_pings_adapter.ts @@ -88,7 +88,10 @@ export const elasticsearchPingsAdapter: UMPingsAdapter = { return results; }, - getLatestMonitorDocs: async ({ callES, dateRangeStart, dateRangeEnd, monitorId, location }) => { + // Get The monitor latest state sorted by timestamp with date range + getLatestMonitorStatus: async ({ callES, dateStart, dateEnd, monitorId }) => { + // TODO: Write tests for this function + const params = { index: INDEX_NAMES.HEARTBEAT, body: { @@ -98,13 +101,12 @@ export const elasticsearchPingsAdapter: UMPingsAdapter = { { range: { '@timestamp': { - gte: dateRangeStart, - lte: dateRangeEnd, + gte: dateStart, + lte: dateEnd, }, }, }, ...(monitorId ? [{ term: { 'monitor.id': monitorId } }] : []), - ...(location ? [{ term: { 'observer.geo.name': location } }] : []), ], }, }, @@ -131,21 +133,45 @@ export const elasticsearchPingsAdapter: UMPingsAdapter = { }; const result = await callES('search', params); - const buckets: any[] = get(result, 'aggregations.by_id.buckets', []); + const ping: any = result.aggregations.by_id.buckets?.[0]?.latest.hits?.hits?.[0] ?? {}; + + return { + ...ping?._source, + timestamp: ping?._source?.['@timestamp'], + }; + }, - return buckets.map( - ({ - latest: { - hits: { hits }, + // Get the monitor meta info regardless of timestamp + getMonitor: async ({ callES, monitorId }) => { + const params = { + index: INDEX_NAMES.HEARTBEAT, + body: { + size: 1, + _source: ['url', 'monitor', 'observer'], + query: { + bool: { + filter: [ + { + term: { + 'monitor.id': monitorId, + }, + }, + ], + }, }, - }) => { - const timestamp = hits[0]._source[`@timestamp`]; - return { - ...hits[0]._source, - timestamp, - }; - } - ); + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }; + + const result = await callES('search', params); + + return result.hits.hits[0]?._source; }, getPingHistogram: async ({ diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts index 4c88da7eca85aa..f9a8de81332d50 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts @@ -9,3 +9,4 @@ export { getHistogramInterval } from './get_histogram_interval'; export { getHistogramIntervalFormatted } from './get_histogram_interval_formatted'; export { parseFilterQuery } from './parse_filter_query'; export { assertCloseTo } from './assert_close_to'; +export { objectValuesToArrays } from './object_to_array'; diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts new file mode 100644 index 00000000000000..334c31c822eaac --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.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; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Converts the top-level fields of an object from an object to an array. + * @param record the obect to map + * @type T the type of the objects/arrays that will be mapped + */ +export const objectValuesToArrays = (record: Record): Record => { + const obj: Record = {}; + Object.keys(record).forEach((key: string) => { + const value = record[key]; + obj[key] = value ? (Array.isArray(value) ? value : [value]) : []; + }); + return obj; +}; diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts index 4ab225076eff22..e64b317e67f988 100644 --- a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts @@ -4,21 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createGetOverviewFilters } from './overview_filters'; import { createGetAllRoute } from './pings'; import { createGetIndexPatternRoute } from './index_pattern'; import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; import { UMRestApiRouteFactory } from './types'; -import { createGetMonitorDetailsRoute, createGetMonitorLocationsRoute } from './monitors'; +import { + createGetMonitorRoute, + createGetMonitorDetailsRoute, + createGetMonitorLocationsRoute, + createGetStatusBarRoute, +} from './monitors'; export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; export { uptimeRouteWrapper } from './uptime_route_wrapper'; export const restApiRoutes: UMRestApiRouteFactory[] = [ + createGetOverviewFilters, createGetAllRoute, createGetIndexPatternRoute, + createGetMonitorRoute, createGetMonitorDetailsRoute, createGetMonitorLocationsRoute, + createGetStatusBarRoute, createGetSnapshotCount, createLogMonitorPageRoute, createLogOverviewPageRoute, diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts index 2279233d49a096..7f1f10081dc4ec 100644 --- a/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/index.ts @@ -6,3 +6,4 @@ export { createGetMonitorDetailsRoute } from './monitors_details'; export { createGetMonitorLocationsRoute } from './monitor_locations'; +export { createGetMonitorRoute, createGetStatusBarRoute } from './status'; diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts new file mode 100644 index 00000000000000..8b1bc04b451109 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/rest_api/monitors/status.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createGetMonitorRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/monitor/selected', + validate: { + query: schema.object({ + monitorId: schema.string(), + }), + }, + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response): Promise => { + const { monitorId } = request.query; + + return response.ok({ + body: { + ...(await libs.pings.getMonitor({ callES, monitorId })), + }, + }); + }, +}); + +export const createGetStatusBarRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/monitor/status', + validate: { + query: schema.object({ + monitorId: schema.string(), + dateStart: schema.string(), + dateEnd: schema.string(), + }), + }, + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response): Promise => { + const { monitorId, dateStart, dateEnd } = request.query; + const result = await libs.pings.getLatestMonitorStatus({ + callES, + monitorId, + dateStart, + dateEnd, + }); + return response.ok({ + body: { + ...result, + }, + }); + }, +}); diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts new file mode 100644 index 00000000000000..ef93253bb5b700 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; +import { objectValuesToArrays } from '../../lib/helper'; + +const arrayOrStringType = schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) +); + +export const createGetOverviewFilters: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/filters', + validate: { + query: schema.object({ + dateRangeStart: schema.string(), + dateRangeEnd: schema.string(), + search: schema.maybe(schema.string()), + locations: arrayOrStringType, + schemes: arrayOrStringType, + ports: arrayOrStringType, + tags: arrayOrStringType, + }), + }, + + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response) => { + const { dateRangeStart, dateRangeEnd, locations, schemes, search, ports, tags } = request.query; + + let parsedSearch: Record | undefined; + if (search) { + try { + parsedSearch = JSON.parse(search); + } catch (e) { + return response.badRequest({ body: { message: e.message } }); + } + } + + const filtersResponse = await libs.monitors.getFilterBar({ + callES, + dateRangeStart, + dateRangeEnd, + search: parsedSearch, + filterOptions: objectValuesToArrays({ + locations, + ports, + schemes, + tags, + }), + }); + + return response.ok({ body: { ...filtersResponse } }); + }, +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts similarity index 62% rename from x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/index.ts rename to x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts index 7087407407c557..dc4e0c66a8183d 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_bar/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { MonitorSSLCertificate } from './monitor_ssl_certificate'; -export { MonitorStatusBar, MonitorStatusBarComponent } from './monitor_status_bar'; +export { createGetOverviewFilters } from './get_overview_filters'; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts b/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts index 8cebe8ce262296..65648ae5f5a959 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts @@ -104,4 +104,5 @@ export type TestSubjects = | 'webhookPathInput' | 'webhookPortInput' | 'webhookMethodSelect' + | 'webhookSchemeSelect' | 'webhookUsernameInput'; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx index 36a5c150eead73..2800b0107da240 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx @@ -257,9 +257,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -324,9 +326,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -387,9 +391,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -461,9 +467,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -487,6 +495,7 @@ describe(' create route', () => { const METHOD = 'put'; const HOST = 'localhost'; const PORT = '9200'; + const SCHEME = 'http'; const PATH = '/test'; const USERNAME = 'test_user'; const PASSWORD = 'test_password'; @@ -510,6 +519,7 @@ describe(' create route', () => { form.setInputValue('webhookMethodSelect', METHOD); form.setInputValue('webhookHostInput', HOST); form.setInputValue('webhookPortInput', PORT); + form.setInputValue('webhookSchemeSelect', SCHEME); form.setInputValue('webhookPathInput', PATH); form.setInputValue('webhookUsernameInput', USERNAME); form.setInputValue('webhookPasswordInput', PASSWORD); @@ -534,6 +544,7 @@ describe(' create route', () => { method: METHOD, host: HOST, port: Number(PORT), + scheme: SCHEME, path: PATH, body: '{\n "message": "Watch [{{ctx.metadata.name}}] has exceeded the threshold"\n}', // Default @@ -551,9 +562,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -639,9 +652,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -707,9 +722,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -759,9 +776,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts index 1eee3d3b7e6ee6..131400a8702c46 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts @@ -168,6 +168,7 @@ describe('', () => { }); const latestRequest = server.requests[server.requests.length - 1]; + const { id, type, @@ -194,9 +195,11 @@ describe('', () => { triggerIntervalUnit, aggType, termSize, + termOrder: 'desc', thresholdComparator, timeWindowSize, timeWindowUnit, + hasTermsAgg: false, threshold: threshold && threshold[0], }) ); diff --git a/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js index 54be8407f207dc..d6f921a75a9ea3 100644 --- a/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js @@ -16,6 +16,7 @@ export class WebhookAction extends BaseAction { this.method = props.method; this.host = props.host; this.port = props.port; + this.scheme = props.scheme; this.path = props.path; this.body = props.body; this.contentType = props.contentType; @@ -30,6 +31,7 @@ export class WebhookAction extends BaseAction { method: this.method, host: this.host, port: this.port, + scheme: this.scheme, path: this.path, body: this.body, contentType: this.contentType, @@ -47,6 +49,7 @@ export class WebhookAction extends BaseAction { method: json.method, host: json.host, port: json.port, + scheme: json.scheme, path: json.path, body: json.body, contentType: json.contentType, @@ -72,6 +75,10 @@ export class WebhookAction extends BaseAction { optionalFields.method = this.method; } + if (this.scheme) { + optionalFields.scheme = this.scheme; + } + if (this.body) { optionalFields.body = this.body; } @@ -108,7 +115,7 @@ export class WebhookAction extends BaseAction { const webhookJson = json && json.actionJson && json.actionJson.webhook; const { errors } = this.validateJson(json.actionJson); - const { path, method, body, auth, headers } = webhookJson; + const { path, method, scheme, body, auth, headers } = webhookJson; const optionalFields = {}; @@ -120,6 +127,10 @@ export class WebhookAction extends BaseAction { optionalFields.method = method; } + if (scheme) { + optionalFields.scheme = scheme; + } + if (body) { optionalFields.body = body; } diff --git a/x-pack/legacy/plugins/watcher/common/types/action_types.ts b/x-pack/legacy/plugins/watcher/common/types/action_types.ts index 123bf0f58db9d5..918e9a933611bd 100644 --- a/x-pack/legacy/plugins/watcher/common/types/action_types.ts +++ b/x-pack/legacy/plugins/watcher/common/types/action_types.ts @@ -56,6 +56,7 @@ export interface WebhookAction extends BaseAction { method?: 'head' | 'get' | 'post' | 'put' | 'delete'; host: string; port: number; + scheme?: 'http' | 'https'; path?: string; body?: string; username?: string; diff --git a/x-pack/legacy/plugins/watcher/public/legacy.ts b/x-pack/legacy/plugins/watcher/public/legacy.ts index d7b85ccfeb7b42..21fcd718ea1b73 100644 --- a/x-pack/legacy/plugins/watcher/public/legacy.ts +++ b/x-pack/legacy/plugins/watcher/public/legacy.ts @@ -43,6 +43,7 @@ routes.when('/management/elasticsearch/watcher/:param1?/:param2?/:param3?/:param app.mount(npStart as any, { element: elem, appBasePath: '/management/elasticsearch/watcher/', + onAppLeave: () => undefined, }); }, }, diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js index d46d9aacb035bd..6f496dd9ee1381 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js @@ -11,23 +11,21 @@ import { i18n } from '@kbn/i18n'; export class WebhookAction extends BaseAction { constructor(props = {}) { super(props); - const defaultJson = JSON.stringify( { message: 'Watch [{{ctx.metadata.name}}] has exceeded the threshold' }, null, 2 ); this.body = get(props, 'body', props.ignoreDefaults ? null : defaultJson); - this.method = get(props, 'method'); this.host = get(props, 'host'); this.port = get(props, 'port'); + this.scheme = get(props, 'scheme', 'http'); this.path = get(props, 'path'); this.username = get(props, 'username'); this.password = get(props, 'password'); this.contentType = get(props, 'contentType'); - - this.fullPath = `${this.host}:${this.port}${this.path}`; + this.fullPath = `${this.host}:${this.port}${this.path ? '/' + this.path : ''}`; } validate() { @@ -112,6 +110,7 @@ export class WebhookAction extends BaseAction { method: this.method, host: this.host, port: this.port, + scheme: this.scheme, path: this.path, body: this.body, username: this.username, diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js index 7611f158fb962a..2383388dd89bf2 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js @@ -256,9 +256,11 @@ export class ThresholdWatch extends BaseWatch { aggField: this.aggField, termSize: this.termSize, termField: this.termField, + termOrder: this.termOrder, thresholdComparator: this.thresholdComparator, timeWindowSize: this.timeWindowSize, timeWindowUnit: this.timeWindowUnit, + hasTermsAgg: this.hasTermsAgg, threshold: comparators[this.thresholdComparator].requiredValues > 1 ? this.threshold diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx b/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx index bdc6f0bcbb7172..be0b551f4a39c8 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx @@ -29,13 +29,15 @@ interface Props { const HTTP_VERBS = ['head', 'get', 'post', 'put', 'delete']; +const SCHEME = ['http', 'https']; + export const WebhookActionFields: React.FunctionComponent = ({ action, editAction, errors, hasErrors, }) => { - const { method, host, port, path, body, username, password } = action; + const { method, host, port, scheme, path, body, username, password } = action; useEffect(() => { editAction({ key: 'contentType', value: 'application/json' }); // set content-type for threshold watch to json by default @@ -65,6 +67,27 @@ export const WebhookActionFields: React.FunctionComponent = ({
+ + + ({ text: verb, value: verb }))} + onChange={e => { + editAction({ key: 'scheme', value: e.target.value }); + }} + /> + + + { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js index bf7889473b60d6..c83fbc0b4564c1 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js @@ -188,50 +188,6 @@ describe('BaseWatch', () => { }); }); - describe('upstreamJson getter method', () => { - let props; - beforeEach(() => { - props = { - id: 'foo', - name: 'bar', - type: 'json', - watchStatus: { - downstreamJson: { - prop1: 'prop1', - prop2: 'prop2', - }, - }, - actions: [ - { - downstreamJson: { - prop1: 'prop3', - prop2: 'prop4', - }, - }, - ], - }; - }); - - it('should return a valid object', () => { - const watch = new BaseWatch(props); - - const actual = watch.upstreamJson; - const expected = { - id: props.id, - watch: { - metadata: { - name: props.name, - xpack: { - type: props.type, - }, - }, - }, - }; - - expect(actual).toEqual(expected); - }); - }); - describe('getPropsFromDownstreamJson method', () => { let downstreamJson; beforeEach(() => { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js index e6d49f7adc19e4..2440d4ef338816 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js @@ -23,12 +23,6 @@ export class JsonWatch extends BaseWatch { return serializeJsonWatch(this.name, this.watch); } - // To Elasticsearch - get upstreamJson() { - const result = super.upstreamJson; - return result; - } - // To Kibana get downstreamJson() { const result = merge({}, super.downstreamJson, { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js index 56150667b609e0..0301c4c95be946 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js @@ -52,26 +52,6 @@ describe('JsonWatch', () => { }); }); - describe('upstreamJson getter method', () => { - it('should return the correct result', () => { - const watch = new JsonWatch({ watch: { foo: 'bar' } }); - const actual = watch.upstreamJson; - const expected = { - id: undefined, - watch: { - foo: 'bar', - metadata: { - xpack: { - type: 'json', - }, - }, - }, - }; - - expect(actual).toEqual(expected); - }); - }); - describe('downstreamJson getter method', () => { let props; beforeEach(() => { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js index 21909c488431fd..e5410588ab566c 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js @@ -55,12 +55,6 @@ export class ThresholdWatch extends BaseWatch { return formatVisualizeData(this, results); } - // To Elasticsearch - get upstreamJson() { - const result = super.upstreamJson; - return result; - } - // To Kibana get downstreamJson() { const result = merge({}, super.downstreamJson, { diff --git a/x-pack/package.json b/x-pack/package.json index ffa593f5728ee3..1e20157831ba58 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -192,6 +192,7 @@ "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", "@turf/boolean-contains": "6.0.1", + "angular": "^1.7.9", "angular-resource": "1.7.8", "angular-sanitize": "1.7.8", "angular-ui-ace": "0.2.3", @@ -342,7 +343,7 @@ "uuid": "3.3.2", "venn.js": "0.2.20", "vscode-languageserver": "^5.2.1", - "webpack": "4.33.0", + "webpack": "4.41.0", "wellknown": "^0.5.0", "xml2js": "^0.4.22", "xregexp": "4.2.4" diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md new file mode 100644 index 00000000000000..c0acb878352079 --- /dev/null +++ b/x-pack/plugins/case/README.md @@ -0,0 +1,9 @@ +# Case Workflow + +*Experimental Feature* + +Elastic is developing a Case Management Workflow. Follow our progress: + +- [Case API Documentation](https://documenter.getpostman.com/view/172706/SW7c2SuF?version=latest) +- [Github Meta](https://github.com/elastic/kibana/issues/50103) + diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json new file mode 100644 index 00000000000000..23e3cc789ad3b3 --- /dev/null +++ b/x-pack/plugins/case/kibana.json @@ -0,0 +1,9 @@ +{ + "configPath": ["xpack", "case"], + "id": "case", + "kibanaVersion": "kibana", + "requiredPlugins": ["security"], + "server": true, + "ui": false, + "version": "8.0.0" +} diff --git a/x-pack/plugins/case/server/config.ts b/x-pack/plugins/case/server/config.ts new file mode 100644 index 00000000000000..a7cb117198f9b7 --- /dev/null +++ b/x-pack/plugins/case/server/config.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + indexPattern: schema.string({ defaultValue: '.case-test-2' }), + secret: schema.string({ defaultValue: 'Cool secret huh?' }), +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/plugins/case/server/constants.ts new file mode 100644 index 00000000000000..276dcd135254a3 --- /dev/null +++ b/x-pack/plugins/case/server/constants.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const CASE_SAVED_OBJECT = 'case-workflow'; +export const CASE_COMMENT_SAVED_OBJECT = 'case-workflow-comment'; diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts new file mode 100644 index 00000000000000..3963debea97955 --- /dev/null +++ b/x-pack/plugins/case/server/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { CasePlugin } from './plugin'; +export { NewCaseFormatted, NewCommentFormatted } from './routes/api/types'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new CasePlugin(initializerContext); diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts new file mode 100644 index 00000000000000..c52461cade0586 --- /dev/null +++ b/x-pack/plugins/case/server/plugin.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first, map } from 'rxjs/operators'; +import { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; +import { ConfigType } from './config'; +import { initCaseApi } from './routes/api'; +import { CaseService } from './services'; +import { PluginSetupContract as SecurityPluginSetup } from '../../security/server'; + +function createConfig$(context: PluginInitializerContext) { + return context.config.create().pipe(map(config => config)); +} + +export interface PluginsSetup { + security: SecurityPluginSetup; +} + +export class CasePlugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup, plugins: PluginsSetup) { + const config = await createConfig$(this.initializerContext) + .pipe(first()) + .toPromise(); + + if (!config.enabled) { + return; + } + const service = new CaseService(this.log); + + this.log.debug( + `Setting up Case Workflow with core contract [${Object.keys( + core + )}] and plugins [${Object.keys(plugins)}]` + ); + + const caseService = await service.setup({ + authentication: plugins.security.authc, + }); + + const router = core.http.createRouter(); + initCaseApi({ + caseService, + router, + }); + } + + public start() { + this.log.debug(`Starting Case Workflow`); + } + + public stop() { + this.log.debug(`Stopping Case Workflow`); + } +} diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts new file mode 100644 index 00000000000000..94ce9627b9ac64 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { Authentication } from '../../../../../security/server'; + +const getCurrentUser = jest.fn().mockReturnValue({ + username: 'awesome', + full_name: 'Awesome D00d', +}); +const getCurrentUserThrow = jest.fn().mockImplementation(() => { + throw new Error('Bad User - the user is not authenticated'); +}); + +export const authenticationMock = { + create: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), + createInvalid: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser: getCurrentUserThrow, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts new file mode 100644 index 00000000000000..360c6de67b2a81 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../constants'; + +export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { + const mockSavedObjectsClientContract = ({ + get: jest.fn((type, id) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + find: jest.fn(findArgs => { + if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return { + total: savedObject.length, + saved_objects: savedObject, + }; + }), + create: jest.fn((type, attributes, references) => { + if (attributes.description === 'Throw an error' || attributes.comment === 'Throw an error') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + if (type === CASE_COMMENT_SAVED_OBJECT) { + return { + type, + id: 'mock-comment', + attributes, + ...references, + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + } + return { + type, + id: 'mock-it', + attributes, + references: [], + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + }), + update: jest.fn((type, id, attributes) => { + if (!savedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return { + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes, + }; + }), + delete: jest.fn((type: string, id: string) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (type === 'case-workflow-comment' && id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return {}; + }), + deleteByNamespace: jest.fn(), + } as unknown) as jest.Mocked; + + return mockSavedObjectsClientContract; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts new file mode 100644 index 00000000000000..e1fec2d6b229ce --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mockCases, mockCasesErrorTriggerData, mockCaseComments } from './mock_saved_objects'; +export { createMockSavedObjectsRepository } from './create_mock_so_repository'; +export { createRouteContext } from './route_contexts'; +export { authenticationMock } from './authc_mock'; +export { createRoute } from './mock_router'; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts new file mode 100644 index 00000000000000..84889c3ac49be1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; +import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; +import { CaseService } from '../../../services'; +import { authenticationMock } from '../__fixtures__'; +import { RouteDeps } from '../index'; + +export const createRoute = async ( + api: (deps: RouteDeps) => void, + method: 'get' | 'post' | 'delete', + badAuth = false +) => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const log = loggingServiceMock.create().get('case'); + + const service = new CaseService(log); + const caseService = await service.setup({ + authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), + }); + + api({ + router, + caseService, + }); + + return router[method].mock.calls[0][1]; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts new file mode 100644 index 00000000000000..d59f0977e69934 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockCases = [ + { + type: 'case-workflow', + id: 'mock-id-1', + attributes: { + created_at: 1574718888885, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-2', + attributes: { + created_at: 1574721120834, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie destroying data!', + title: 'Damaging Data Destruction Detected', + state: 'open', + tags: ['Data Destruction'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:00.900Z', + version: 'WzQsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-3', + attributes: { + created_at: 1574721137881, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + state: 'open', + tags: ['LOLBins'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, +]; + +export const mockCasesErrorTriggerData = [ + { + id: 'valid-id', + }, + { + id: 'bad-guy', + }, +]; + +export const mockCaseComments = [ + { + type: 'case-workflow-comment', + id: 'mock-comment-1', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574718900112, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:00.177Z', + version: 'WzEsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-2', + attributes: { + comment: 'Well I decided to update my comment. So what? Deal with it.', + created_at: 1574718902724, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:14.633Z', + version: 'WzMsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-3', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574721150542, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-3', + }, + ], + updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts new file mode 100644 index 00000000000000..b1881e394e796a --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'src/core/server'; + +export const createRouteContext = (client: any) => { + return ({ + core: { + savedObjects: { + client, + }, + }, + } as unknown) as RequestHandlerContext; +}; diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts new file mode 100644 index 00000000000000..9ea42ba42406b1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCaseApi } from '../delete_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCaseApi, 'delete'); + }); + it(`deletes the case. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteCase service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); + it(`returns an error when thrown from getAllCaseComments service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'valid-id', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts new file mode 100644 index 00000000000000..e50b3cbaa9c9a1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCommentApi } from '../delete_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCommentApi, 'delete'); + }); + it(`deletes the comment. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts new file mode 100644 index 00000000000000..2f8a229c08f29c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initGetAllCasesApi } from '../get_all_cases'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET all cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetAllCasesApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'get', + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.saved_objects).toHaveLength(3); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts new file mode 100644 index 00000000000000..3c5f8e52d19466 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initGetCaseApi } from '../get_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCaseApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCases.find(s => s.id === 'mock-id-1')); + expect(response.payload.comments).toBeUndefined(); + }); + it(`returns an error when thrown from getCase`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'abcdefg', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`returns the case with case comments when includeComments is true`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.comments.saved_objects).toHaveLength(3); + }); + it(`returns an error when thrown from getAllCaseComments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'bad-guy', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts new file mode 100644 index 00000000000000..9b6a1e435838bd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initGetCommentApi } from '../get_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCommentApi, 'get'); + }); + it(`returns the comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'mock-comment-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCaseComments.find(s => s.id === 'mock-comment-1')); + }); + it(`returns an error when getComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts new file mode 100644 index 00000000000000..bb688dde4c58f8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_case.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCaseApi } from '../post_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCaseApi, 'post'); + }); + it(`Posts a new case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-it'); + expect(response.payload.attributes.created_by.username).toEqual('awesome'); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'Throw an error', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['error'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCaseApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts new file mode 100644 index 00000000000000..0c059b7f15ea48 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCommentApi } from '../post_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCommentApi, 'post'); + }); + it(`Posts a new comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment'); + expect(response.payload.references[0].id).toEqual('mock-id-1'); + }); + it(`Returns an error if the case does not exist`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'this-is-not-real', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Throw an error', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCommentApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts new file mode 100644 index 00000000000000..7ed478d2e7c019 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initUpdateCaseApi } from '../update_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCaseApi, 'post'); + }); + it(`Updates a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-id-1'); + expect(response.payload.attributes.state).toEqual('closed'); + }); + it(`Returns an error if updateCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-does-not-exist', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts new file mode 100644 index 00000000000000..8aa84b45b7dbb2 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initUpdateCommentApi } from '../update_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCommentApi, 'post'); + }); + it(`Updates a comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-1', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment-1'); + expect(response.payload.attributes.comment).toEqual('Update my comment'); + }); + it(`Returns an error if updateComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-does-not-exist', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/delete_case.ts b/x-pack/plugins/case/server/routes/api/delete_case.ts new file mode 100644 index 00000000000000..a5ae72b8b46ff0 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_case.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCaseApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + let allCaseComments; + try { + await caseService.deleteCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + allCaseComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + if (allCaseComments.saved_objects.length > 0) { + await Promise.all( + allCaseComments.saved_objects.map(({ id }) => + caseService.deleteComment({ + client: context.core.savedObjects.client, + commentId: id, + }) + ) + ); + } + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/delete_comment.ts b/x-pack/plugins/case/server/routes/api/delete_comment.ts new file mode 100644 index 00000000000000..4a540dd9fd69ff --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_comment.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCommentApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/comments/{comment_id}', + validate: { + params: schema.object({ + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.savedObjects.client; + try { + await caseService.deleteComment({ + client, + commentId: request.params.comment_id, + }); + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts new file mode 100644 index 00000000000000..cc4956ead1bd78 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}/comments', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: theComments }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts new file mode 100644 index 00000000000000..749a183dfe9807 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases', + validate: false, + }, + async (context, request, response) => { + try { + const cases = await caseService.getAllCases({ + client: context.core.savedObjects.client, + }); + return response.ok({ body: cases }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/get_case.ts new file mode 100644 index 00000000000000..6aad22a1ebf1b6 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_case.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({ + includeComments: schema.string({ defaultValue: 'true' }), + }), + }, + }, + async (context, request, response) => { + let theCase; + const includeComments = JSON.parse(request.query.includeComments); + try { + theCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + if (!includeComments) { + return response.ok({ body: theCase }); + } + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: { ...theCase, comments: theComments } }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/get_comment.ts new file mode 100644 index 00000000000000..6fd507d89738da --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_comment.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCommentApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/comments/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + }); + return response.ok({ body: theComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts new file mode 100644 index 00000000000000..11ef91d539e87b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { initDeleteCommentApi } from './delete_comment'; +import { initDeleteCaseApi } from './delete_case'; +import { initGetAllCaseCommentsApi } from './get_all_case_comments'; +import { initGetAllCasesApi } from './get_all_cases'; +import { initGetCaseApi } from './get_case'; +import { initGetCommentApi } from './get_comment'; +import { initPostCaseApi } from './post_case'; +import { initPostCommentApi } from './post_comment'; +import { initUpdateCaseApi } from './update_case'; +import { initUpdateCommentApi } from './update_comment'; +import { CaseServiceSetup } from '../../services'; + +export interface RouteDeps { + caseService: CaseServiceSetup; + router: IRouter; +} + +export function initCaseApi(deps: RouteDeps) { + initGetAllCaseCommentsApi(deps); + initGetAllCasesApi(deps); + initGetCaseApi(deps); + initGetCommentApi(deps); + initDeleteCaseApi(deps); + initDeleteCommentApi(deps); + initPostCaseApi(deps); + initPostCommentApi(deps); + initUpdateCaseApi(deps); + initUpdateCommentApi(deps); +} diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts new file mode 100644 index 00000000000000..e5aa0a3548b48f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_case.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { formatNewCase, wrapError } from './utils'; +import { NewCaseSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initPostCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases', + validate: { + body: NewCaseSchema, + }, + }, + async (context, request, response) => { + let createdBy; + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + + try { + const newCase = await caseService.postNewCase({ + client: context.core.savedObjects.client, + attributes: formatNewCase(request.body, { + ...createdBy, + }), + }); + return response.ok({ body: newCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts new file mode 100644 index 00000000000000..3f4592f5bb11f3 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_comment.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { formatNewComment, wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; +import { CASE_SAVED_OBJECT } from '../../constants'; + +export function initPostCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}/comment', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + let createdBy; + let newComment; + try { + await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + newComment = await caseService.postNewComment({ + client: context.core.savedObjects.client, + attributes: formatNewComment({ + newComment: request.body, + ...createdBy, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: request.params.id, + }, + ], + }); + + return response.ok({ body: newComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts new file mode 100644 index 00000000000000..4a4a0c3a11e362 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/schema.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const UserSchema = schema.object({ + username: schema.string(), + full_name: schema.maybe(schema.string()), +}); + +export const NewCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const CommentSchema = schema.object({ + comment: schema.string(), + created_at: schema.number(), + created_by: UserSchema, +}); + +export const UpdatedCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const NewCaseSchema = schema.object({ + assignees: schema.arrayOf(UserSchema, { defaultValue: [] }), + description: schema.string(), + title: schema.string(), + state: schema.oneOf([schema.literal('open'), schema.literal('closed')], { defaultValue: 'open' }), + tags: schema.arrayOf(schema.string(), { defaultValue: [] }), + case_type: schema.string(), +}); + +export const UpdatedCaseSchema = schema.object({ + assignees: schema.maybe(schema.arrayOf(UserSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + state: schema.maybe(schema.oneOf([schema.literal('open'), schema.literal('closed')])), + tags: schema.maybe(schema.arrayOf(schema.string())), + case_type: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts new file mode 100644 index 00000000000000..d943e4e5fd7dd7 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + CommentSchema, + NewCaseSchema, + NewCommentSchema, + UpdatedCaseSchema, + UpdatedCommentSchema, + UserSchema, +} from './schema'; + +export type NewCaseType = TypeOf; +export type NewCommentFormatted = TypeOf; +export type NewCommentType = TypeOf; +export type UpdatedCaseTyped = TypeOf; +export type UpdatedCommentType = TypeOf; +export type UserType = TypeOf; + +export interface NewCaseFormatted extends NewCaseType { + created_at: number; + created_by: UserType; +} + +export interface UpdatedCaseType { + assignees?: UpdatedCaseTyped['assignees']; + description?: UpdatedCaseTyped['description']; + title?: UpdatedCaseTyped['title']; + state?: UpdatedCaseTyped['state']; + tags?: UpdatedCaseTyped['tags']; + case_type?: UpdatedCaseTyped['case_type']; +} diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts new file mode 100644 index 00000000000000..52c8cab0022dd1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_case.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from './utils'; +import { RouteDeps } from '.'; +import { UpdatedCaseSchema } from './schema'; + +export function initUpdateCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: UpdatedCaseSchema, + }, + }, + async (context, request, response) => { + try { + const updatedCase = await caseService.updateCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts new file mode 100644 index 00000000000000..e1ee6029e8e4fc --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_comment.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initUpdateCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/comment/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + try { + const updatedComment = await caseService.updateComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts new file mode 100644 index 00000000000000..c6e33dbb8433bd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/utils.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { boomify, isBoom } from 'boom'; +import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; +import { + NewCaseType, + NewCaseFormatted, + NewCommentType, + NewCommentFormatted, + UserType, +} from './types'; + +export const formatNewCase = ( + newCase: NewCaseType, + { full_name, username }: { full_name?: string; username: string } +): NewCaseFormatted => ({ + created_at: new Date().valueOf(), + created_by: { full_name, username }, + ...newCase, +}); + +interface NewCommentArgs { + newComment: NewCommentType; + full_name?: UserType['full_name']; + username: UserType['username']; +} +export const formatNewComment = ({ + newComment, + full_name, + username, +}: NewCommentArgs): NewCommentFormatted => ({ + ...newComment, + created_at: new Date().valueOf(), + created_by: { full_name, username }, +}); + +export function wrapError(error: any): CustomHttpResponseOptions { + const boom = isBoom(error) ? error : boomify(error); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts new file mode 100644 index 00000000000000..684d905a5c71fe --- /dev/null +++ b/x-pack/plugins/case/server/services/index.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + KibanaRequest, + KibanaResponseFactory, + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, + SavedObjectReference, +} from 'kibana/server'; +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; +import { + NewCaseFormatted, + NewCommentFormatted, + UpdatedCaseType, + UpdatedCommentType, +} from '../routes/api/types'; +import { + AuthenticatedUser, + PluginSetupContract as SecurityPluginSetup, +} from '../../../security/server'; + +interface ClientArgs { + client: SavedObjectsClientContract; +} + +interface GetCaseArgs extends ClientArgs { + caseId: string; +} +interface GetCommentArgs extends ClientArgs { + commentId: string; +} +interface PostCaseArgs extends ClientArgs { + attributes: NewCaseFormatted; +} + +interface PostCommentArgs extends ClientArgs { + attributes: NewCommentFormatted; + references: SavedObjectReference[]; +} +interface UpdateCaseArgs extends ClientArgs { + caseId: string; + updatedAttributes: UpdatedCaseType; +} +interface UpdateCommentArgs extends ClientArgs { + commentId: string; + updatedAttributes: UpdatedCommentType; +} + +interface GetUserArgs { + request: KibanaRequest; + response: KibanaResponseFactory; +} + +interface CaseServiceDeps { + authentication: SecurityPluginSetup['authc']; +} +export interface CaseServiceSetup { + deleteCase(args: GetCaseArgs): Promise<{}>; + deleteComment(args: GetCommentArgs): Promise<{}>; + getAllCases(args: ClientArgs): Promise; + getAllCaseComments(args: GetCaseArgs): Promise; + getCase(args: GetCaseArgs): Promise; + getComment(args: GetCommentArgs): Promise; + getUser(args: GetUserArgs): Promise; + postNewCase(args: PostCaseArgs): Promise; + postNewComment(args: PostCommentArgs): Promise; + updateCase(args: UpdateCaseArgs): Promise; + updateComment(args: UpdateCommentArgs): Promise; +} + +export class CaseService { + constructor(private readonly log: Logger) {} + public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ + deleteCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + deleteComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + getComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getAllCases: async ({ client }: ClientArgs) => { + try { + this.log.debug(`Attempting to GET all cases`); + return await client.find({ type: CASE_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + }, + getAllCaseComments: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET all comments for case ${caseId}`); + return await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); + throw error; + } + }, + getUser: async ({ request, response }: GetUserArgs) => { + let user; + try { + this.log.debug(`Attempting to authenticate a user`); + user = await authentication!.getCurrentUser(request); + } catch (error) { + this.log.debug(`Error on GET user: ${error}`); + throw error; + } + if (!user) { + this.log.debug(`Error on GET user: Bad User`); + throw new Error('Bad User - the user is not authenticated'); + } + return user; + }, + postNewCase: async ({ client, attributes }: PostCaseArgs) => { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + }, + postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + }, + updateCase: async ({ client, caseId, updatedAttributes }: UpdateCaseArgs) => { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + }, + updateComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { + ...updatedAttributes, + }); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + }, + }); +} diff --git a/x-pack/plugins/endpoint/server/routes/endpoints.ts b/x-pack/plugins/endpoint/server/routes/endpoints.ts index 59430947d97da7..9d2babc61f11f9 100644 --- a/x-pack/plugins/endpoint/server/routes/endpoints.ts +++ b/x-pack/plugins/endpoint/server/routes/endpoints.ts @@ -66,6 +66,7 @@ function mapToEndpointResultList( queryParams: Record, searchResponse: SearchResponse ): EndpointResultList { + const totalNumberOfEndpoints = searchResponse?.aggregations?.total?.value || 0; if (searchResponse.hits.hits.length > 0) { return { request_page_size: queryParams.size, @@ -74,13 +75,13 @@ function mapToEndpointResultList( .map(response => response.inner_hits.most_recent.hits.hits) .flatMap(data => data as HitSource) .map(entry => entry._source), - total: searchResponse.aggregations.total.value, + total: totalNumberOfEndpoints, }; } else { return { request_page_size: queryParams.size, request_index: queryParams.from, - total: 0, + total: totalNumberOfEndpoints, endpoints: [], }; } diff --git a/x-pack/plugins/graph/server/routes/explore.ts b/x-pack/plugins/graph/server/routes/explore.ts index 0a5b9f62f12a1c..125378891151b5 100644 --- a/x-pack/plugins/graph/server/routes/explore.ts +++ b/x-pack/plugins/graph/server/routes/explore.ts @@ -71,7 +71,11 @@ export function registerExploreRoute({ throw Boom.badRequest(relevantCause.reason); } - throw Boom.boomify(error); + return response.internalError({ + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/graph/server/routes/search.ts b/x-pack/plugins/graph/server/routes/search.ts index 400cdc4e82b6e6..91b404dc7cb915 100644 --- a/x-pack/plugins/graph/server/routes/search.ts +++ b/x-pack/plugins/graph/server/routes/search.ts @@ -6,7 +6,6 @@ import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { LicenseState, verifyApiAccess } from '../lib/license_state'; export function registerSearchRoute({ @@ -53,7 +52,12 @@ export function registerSearchRoute({ }, }); } catch (error) { - throw Boom.boomify(error, { statusCode: error.statusCode || 500 }); + return response.customError({ + statusCode: error.statusCode || 500, + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/security/common/licensing/license_features.ts b/x-pack/plugins/security/common/licensing/license_features.ts index 6b6c86d48c21e4..33f8370a1b43ea 100644 --- a/x-pack/plugins/security/common/licensing/license_features.ts +++ b/x-pack/plugins/security/common/licensing/license_features.ts @@ -23,6 +23,11 @@ export interface SecurityLicenseFeatures { */ readonly showLinks: boolean; + /** + * Indicates whether we show the Role Mappings UI. + */ + readonly showRoleMappingsManagement: boolean; + /** * Indicates whether we allow users to define document level security in roles. */ diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index f4fa5e00e2387d..df2d66a036039f 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -17,6 +17,7 @@ describe('license features', function() { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, layout: 'error-es-unavailable', @@ -34,6 +35,7 @@ describe('license features', function() { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, layout: 'error-xpack-unavailable', @@ -63,6 +65,7 @@ describe('license features', function() { "layout": "error-xpack-unavailable", "showLinks": false, "showLogin": true, + "showRoleMappingsManagement": false, }, ] `); @@ -79,6 +82,7 @@ describe('license features', function() { "linksMessage": "Access is denied because Security is disabled in Elasticsearch.", "showLinks": false, "showLogin": false, + "showRoleMappingsManagement": false, }, ] `); @@ -87,10 +91,12 @@ describe('license features', function() { } }); - it('should show login page and other security elements, allow RBAC but forbid document level security if license is not platinum or trial.', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); + it('should show login page and other security elements, allow RBAC but forbid role mappings and document level security if license is basic.', () => { + const mockRawLicense = licensingMock.createLicense({ + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const getFeatureSpy = jest.spyOn(mockRawLicense, 'getFeature'); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -99,18 +105,19 @@ describe('license features', function() { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, }); - expect(mockRawLicense.getFeature).toHaveBeenCalledTimes(1); - expect(mockRawLicense.getFeature).toHaveBeenCalledWith('security'); + expect(getFeatureSpy).toHaveBeenCalledTimes(1); + expect(getFeatureSpy).toHaveBeenCalledWith('security'); }); it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true }); + const mockRawLicense = licensingMock.createLicense({ + features: { security: { isEnabled: false, isAvailable: true } }, + }); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -119,6 +126,7 @@ describe('license features', function() { showLogin: false, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -126,12 +134,31 @@ describe('license features', function() { }); }); - it('should allow to login, allow RBAC and document level security if license >= platinum', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockImplementation(license => { - return license === 'trial' || license === 'platinum' || license === 'enterprise'; + it('should allow role mappings, but not DLS/FLS if license = gold', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'gold', type: 'gold' }, + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const serviceSetup = new SecurityLicenseService().setup({ + license$: of(mockRawLicense), + }); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: true, + showLinks: true, + showRoleMappingsManagement: true, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: true, + }); + }); + + it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'platinum', type: 'platinum' }, + features: { security: { isEnabled: true, isAvailable: true } }, }); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -140,6 +167,7 @@ describe('license features', function() { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement: true, allowRoleDocumentLevelSecurity: true, allowRoleFieldLevelSecurity: true, allowRbac: true, diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 0f9da03f9f6ec6..e6d2eff49ed0df 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -70,6 +70,7 @@ export class SecurityLicenseService { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -85,6 +86,7 @@ export class SecurityLicenseService { showLogin: false, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -92,11 +94,13 @@ export class SecurityLicenseService { }; } + const showRoleMappingsManagement = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter, allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 226ea3b70afe2a..f3c65ed7e3cf17 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -12,3 +12,10 @@ export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; export { KibanaPrivileges } from './kibana_privileges'; +export { + InlineRoleTemplate, + StoredRoleTemplate, + InvalidRoleTemplate, + RoleTemplate, + RoleMapping, +} from './role_mapping'; diff --git a/x-pack/plugins/security/common/model/role_mapping.ts b/x-pack/plugins/security/common/model/role_mapping.ts new file mode 100644 index 00000000000000..99de183f648f7a --- /dev/null +++ b/x-pack/plugins/security/common/model/role_mapping.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; + * you may not use this file except in compliance with the Elastic License. + */ + +interface RoleMappingAnyRule { + any: RoleMappingRule[]; +} + +interface RoleMappingAllRule { + all: RoleMappingRule[]; +} + +interface RoleMappingFieldRule { + field: Record; +} + +interface RoleMappingExceptRule { + except: RoleMappingRule; +} + +type RoleMappingRule = + | RoleMappingAnyRule + | RoleMappingAllRule + | RoleMappingFieldRule + | RoleMappingExceptRule; + +type RoleTemplateFormat = 'string' | 'json'; + +export interface InlineRoleTemplate { + template: { source: string }; + format?: RoleTemplateFormat; +} + +export interface StoredRoleTemplate { + template: { id: string }; + format?: RoleTemplateFormat; +} + +export interface InvalidRoleTemplate { + template: string; + format?: RoleTemplateFormat; +} + +export type RoleTemplate = InlineRoleTemplate | StoredRoleTemplate | InvalidRoleTemplate; + +export interface RoleMapping { + name: string; + enabled: boolean; + roles?: string[]; + role_templates?: RoleTemplate[]; + rules: RoleMappingRule | {}; + metadata: Record; +} diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts index 60d947bd658637..996dcb685f29bc 100644 --- a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -573,4 +573,64 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen fmt: '/_security/delegate_pki', }, }); + + /** + * Retrieves all configured role mappings. + * + * @returns {{ [roleMappingName]: { enabled: boolean; roles: string[]; rules: Record} }} + */ + shield.getRoleMappings = ca({ + method: 'GET', + urls: [ + { + fmt: '/_security/role_mapping', + }, + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + /** + * Saves the specified role mapping. + */ + shield.saveRoleMapping = ca({ + method: 'POST', + needBody: true, + urls: [ + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + /** + * Deletes the specified role mapping. + */ + shield.deleteRoleMapping = ca({ + method: 'DELETE', + urls: [ + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 33f554be5caa3b..17e49b8cf40d30 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -17,6 +17,7 @@ import { Plugin, PluginSetupContract, PluginSetupDependencies } from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported // functions or removal of exports should be considered as a breaking change. export { + Authentication, AuthenticationResult, DeauthenticationResult, CreateAPIKeyResult, @@ -24,6 +25,7 @@ export { InvalidateAPIKeyResult, } from './authentication'; export { PluginSetupContract }; +export { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index ade840e7ca4959..01df67cacb800d 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -14,6 +14,7 @@ import { defineAuthorizationRoutes } from './authorization'; import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; +import { defineRoleMappingRoutes } from './role_mapping'; /** * Describes parameters used to define HTTP routes. @@ -35,4 +36,5 @@ export function defineRoutes(params: RouteDefinitionParams) { defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); + defineRoleMappingRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts new file mode 100644 index 00000000000000..e8a8a7216330b6 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { defineRoleMappingDeleteRoutes } from './delete'; + +describe('DELETE role mappings', () => { + it('allows a role mapping to be deleted', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ acknowledged: true }); + + defineRoleMappingDeleteRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ acknowledged: true }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect( + mockScopedClusterClient.callAsCurrentUser + ).toHaveBeenCalledWith('shield.deleteRoleMapping', { name }); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingDeleteRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.ts new file mode 100644 index 00000000000000..dc11bcd914b35b --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingDeleteRoutes(params: RouteDefinitionParams) { + const { clusterClient, router } = params; + + router.delete( + { + path: '/internal/security/role_mapping/{name}', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const deleteResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.deleteRoleMapping', { + name: request.params.name, + }); + return response.ok({ body: deleteResponse }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts b/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts new file mode 100644 index 00000000000000..f2c48fd370434c --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + kibanaResponseFactory, + RequestHandlerContext, + IClusterClient, +} from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server'; +import { defineRoleMappingFeatureCheckRoute } from './feature_check'; + +interface TestOptions { + licenseCheckResult?: LicenseCheck; + canManageRoleMappings?: boolean; + nodeSettingsResponse?: Record; + xpackUsageResponse?: Record; + internalUserClusterClientImpl?: IClusterClient['callAsInternalUser']; + asserts: { statusCode: number; result?: Record }; +} + +const defaultXpackUsageResponse = { + security: { + realms: { + native: { + available: true, + enabled: true, + }, + pki: { + available: true, + enabled: true, + }, + }, + }, +}; + +const getDefaultInternalUserClusterClientImpl = ( + nodeSettingsResponse: TestOptions['nodeSettingsResponse'], + xpackUsageResponse: TestOptions['xpackUsageResponse'] +) => + ((async (endpoint: string, clientParams: Record) => { + if (!clientParams) throw new TypeError('expected clientParams'); + + if (endpoint === 'nodes.info') { + return nodeSettingsResponse; + } + + if (endpoint === 'transport.request') { + if (clientParams.path === '/_xpack/usage') { + return xpackUsageResponse; + } + } + + throw new Error(`unexpected endpoint: ${endpoint}`); + }) as unknown) as TestOptions['internalUserClusterClientImpl']; + +describe('GET role mappings feature check', () => { + const getFeatureCheckTest = ( + description: string, + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + canManageRoleMappings = true, + nodeSettingsResponse = {}, + xpackUsageResponse = defaultXpackUsageResponse, + internalUserClusterClientImpl = getDefaultInternalUserClusterClientImpl( + nodeSettingsResponse, + xpackUsageResponse + ), + asserts, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockRouteDefinitionParams.clusterClient.callAsInternalUser.mockImplementation( + internalUserClusterClientImpl + ); + + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method, payload) => { + if (method === 'shield.hasPrivileges') { + return { + has_all_requested: canManageRoleMappings, + }; + } + }); + + defineRoleMappingFeatureCheckRoute(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/_check_role_mapping_features`, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + getFeatureCheckTest('allows both script types with the default settings', { + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('allows both script types when explicitly enabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['stored', 'inline'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('disallows stored scripts when disabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['inline'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: false, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('disallows inline scripts when disabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['stored'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: false, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('indicates incompatible realms when only native and file are enabled', { + xpackUsageResponse: { + security: { + realms: { + native: { + available: true, + enabled: true, + }, + file: { + available: true, + enabled: true, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: false, + }, + }, + }); + + getFeatureCheckTest('indicates canManageRoleMappings=false for users without `manage_security`', { + canManageRoleMappings: false, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: false, + }, + }, + }); + + getFeatureCheckTest( + 'falls back to allowing both script types if there is an error retrieving node settings', + { + internalUserClusterClientImpl: (() => { + return Promise.reject(new Error('something bad happened')); + }) as TestOptions['internalUserClusterClientImpl'], + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: false, + }, + }, + } + ); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts b/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts new file mode 100644 index 00000000000000..2be4f4cd89177e --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger, IClusterClient } from 'src/core/server'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +interface NodeSettingsResponse { + nodes: { + [nodeId: string]: { + settings: { + script: { + allowed_types?: string[]; + allowed_contexts?: string[]; + }; + }; + }; + }; +} + +interface XPackUsageResponse { + security: { + realms: { + [realmName: string]: { + available: boolean; + enabled: boolean; + }; + }; + }; +} + +const INCOMPATIBLE_REALMS = ['file', 'native']; + +export function defineRoleMappingFeatureCheckRoute({ + router, + clusterClient, + logger, +}: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/_check_role_mapping_features', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { has_all_requested: canManageRoleMappings } = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.hasPrivileges', { + body: { + cluster: ['manage_security'], + }, + }); + + if (!canManageRoleMappings) { + return response.ok({ + body: { + canManageRoleMappings, + }, + }); + } + + const enabledFeatures = await getEnabledRoleMappingsFeatures(clusterClient, logger); + + return response.ok({ + body: { + ...enabledFeatures, + canManageRoleMappings, + }, + }); + }) + ); +} + +async function getEnabledRoleMappingsFeatures(clusterClient: IClusterClient, logger: Logger) { + logger.debug(`Retrieving role mappings features`); + + const nodeScriptSettingsPromise: Promise = clusterClient + .callAsInternalUser('nodes.info', { + filterPath: 'nodes.*.settings.script', + }) + .catch(error => { + // fall back to assuming that node settings are unset/at their default values. + // this will allow the role mappings UI to permit both role template script types, + // even if ES will disallow it at mapping evaluation time. + logger.error(`Error retrieving node settings for role mappings: ${error}`); + return {}; + }); + + const xpackUsagePromise: Promise = clusterClient + // `transport.request` is potentially unsafe when combined with untrusted user input. + // Do not augment with such input. + .callAsInternalUser('transport.request', { + method: 'GET', + path: '/_xpack/usage', + }) + .catch(error => { + // fall back to no external realms configured. + // this will cause a warning in the UI about no compatible realms being enabled, but will otherwise allow + // the mappings screen to function correctly. + logger.error(`Error retrieving XPack usage info for role mappings: ${error}`); + return { + security: { + realms: {}, + }, + } as XPackUsageResponse; + }); + + const [nodeScriptSettings, xpackUsage] = await Promise.all([ + nodeScriptSettingsPromise, + xpackUsagePromise, + ]); + + let canUseStoredScripts = true; + let canUseInlineScripts = true; + if (usesCustomScriptSettings(nodeScriptSettings)) { + canUseStoredScripts = Object.values(nodeScriptSettings.nodes).some(node => { + const allowedTypes = node.settings.script.allowed_types; + return !allowedTypes || allowedTypes.includes('stored'); + }); + + canUseInlineScripts = Object.values(nodeScriptSettings.nodes).some(node => { + const allowedTypes = node.settings.script.allowed_types; + return !allowedTypes || allowedTypes.includes('inline'); + }); + } + + const hasCompatibleRealms = Object.entries(xpackUsage.security.realms).some( + ([realmName, realm]) => { + return !INCOMPATIBLE_REALMS.includes(realmName) && realm.available && realm.enabled; + } + ); + + return { + hasCompatibleRealms, + canUseStoredScripts, + canUseInlineScripts, + }; +} + +function usesCustomScriptSettings( + nodeResponse: NodeSettingsResponse | {} +): nodeResponse is NodeSettingsResponse { + return nodeResponse.hasOwnProperty('nodes'); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.test.ts b/x-pack/plugins/security/server/routes/role_mapping/get.test.ts new file mode 100644 index 00000000000000..c60d5518097bac --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/get.test.ts @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { defineRoleMappingGetRoutes } from './get'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; + +const mockRoleMappingResponse = { + mapping1: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + mapping2: { + enabled: true, + role_templates: [{ template: JSON.stringify({ source: 'foo_{{username}}' }) }], + rules: { + any: [ + { + field: { + dn: 'CN=admin,OU=example,O=com', + }, + }, + { + field: { + username: 'admin_*', + }, + }, + ], + }, + }, + mapping3: { + enabled: true, + role_templates: [{ template: 'template with invalid json' }], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, +}; + +describe('GET role mappings', () => { + it('returns all role mappings', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockRoleMappingResponse); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual([ + { + name: 'mapping1', + enabled: true, + roles: ['foo', 'bar'], + role_templates: [], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + { + name: 'mapping2', + enabled: true, + role_templates: [{ template: { source: 'foo_{{username}}' } }], + rules: { + any: [ + { + field: { + dn: 'CN=admin,OU=example,O=com', + }, + }, + { + field: { + username: 'admin_*', + }, + }, + ], + }, + }, + { + name: 'mapping3', + enabled: true, + role_templates: [{ template: 'template with invalid json' }], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + ]); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getRoleMappings', + { name: undefined } + ); + }); + + it('returns role mapping by name', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ + mapping1: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + }); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ + name: 'mapping1', + enabled: true, + roles: ['foo', 'bar'], + role_templates: [], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getRoleMappings', + { name } + ); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + + it('returns a 404 when the role mapping is not found', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + Boom.notFound('role mapping not found!') + ); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(404); + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect( + mockScopedClusterClient.callAsCurrentUser + ).toHaveBeenCalledWith('shield.getRoleMappings', { name }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.ts b/x-pack/plugins/security/server/routes/role_mapping/get.ts new file mode 100644 index 00000000000000..9cd5cf83092e18 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/get.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { RoleMapping } from '../../../../../legacy/plugins/security/common/model'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +interface RoleMappingsResponse { + [roleMappingName: string]: Omit; +} + +export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { + const { clusterClient, logger, router } = params; + + router.get( + { + path: '/internal/security/role_mapping/{name?}', + validate: { + params: schema.object({ + name: schema.maybe(schema.string()), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const expectSingleEntity = typeof request.params.name === 'string'; + + try { + const roleMappingsResponse: RoleMappingsResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRoleMappings', { + name: request.params.name, + }); + + const mappings = Object.entries(roleMappingsResponse).map(([name, mapping]) => { + return { + name, + ...mapping, + role_templates: (mapping.role_templates || []).map(entry => { + return { + ...entry, + template: tryParseRoleTemplate(entry.template as string), + }; + }), + } as RoleMapping; + }); + + if (expectSingleEntity) { + return response.ok({ body: mappings[0] }); + } + return response.ok({ body: mappings }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); + + /** + * While role templates are normally persisted as objects via the create API, they are stored internally as strings. + * As a result, the ES APIs to retrieve role mappings represent the templates as strings, so we have to attempt + * to parse them back out. ES allows for invalid JSON to be stored, so we have to account for that as well. + * + * @param roleTemplate the string-based template to parse + */ + function tryParseRoleTemplate(roleTemplate: string) { + try { + return JSON.parse(roleTemplate); + } catch (e) { + logger.debug(`Role template is not valid JSON: ${e}`); + return roleTemplate; + } + } +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/index.ts b/x-pack/plugins/security/server/routes/role_mapping/index.ts new file mode 100644 index 00000000000000..1bd90e8c1fae33 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/index.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { defineRoleMappingFeatureCheckRoute } from './feature_check'; +import { defineRoleMappingGetRoutes } from './get'; +import { defineRoleMappingPostRoutes } from './post'; +import { defineRoleMappingDeleteRoutes } from './delete'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingRoutes(params: RouteDefinitionParams) { + defineRoleMappingFeatureCheckRoute(params); + defineRoleMappingGetRoutes(params); + defineRoleMappingPostRoutes(params); + defineRoleMappingDeleteRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.test.ts b/x-pack/plugins/security/server/routes/role_mapping/post.test.ts new file mode 100644 index 00000000000000..7d820d668a6da1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/post.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { defineRoleMappingPostRoutes } from './post'; + +describe('POST role mappings', () => { + it('allows a role mapping to be created', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ created: true }); + + defineRoleMappingPostRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + body: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ created: true }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.saveRoleMapping', + { + name, + body: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + } + ); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingPostRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.ts b/x-pack/plugins/security/server/routes/role_mapping/post.ts new file mode 100644 index 00000000000000..bf9112be4ad3f4 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/post.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingPostRoutes(params: RouteDefinitionParams) { + const { clusterClient, router } = params; + + router.post( + { + path: '/internal/security/role_mapping/{name}', + validate: { + params: schema.object({ + name: schema.string(), + }), + body: schema.object({ + roles: schema.arrayOf(schema.string(), { defaultValue: [] }), + role_templates: schema.arrayOf( + schema.object({ + // Not validating `template` because the ES API currently accepts invalid payloads here. + // We allow this as well so that existing mappings can be updated via our Role Management UI + template: schema.any(), + format: schema.maybe( + schema.oneOf([schema.literal('string'), schema.literal('json')]) + ), + }), + { defaultValue: [] } + ), + enabled: schema.boolean(), + // Also lax on validation here because the real rules get quite complex, + // and keeping this in sync (and testable!) with ES could prove problematic. + // We do not interpret any of these rules within this route handler; + // they are simply passed to ES for processing. + rules: schema.object({}, { allowUnknowns: true }), + metadata: schema.object({}, { allowUnknowns: true }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const saveResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.saveRoleMapping', { + name: request.params.name, + body: request.body, + }); + return response.ok({ body: saveResponse }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 09d71814e5bf09..3b0c188318309d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -441,7 +441,6 @@ "common.ui.flotCharts.tueLabel": "火", "common.ui.flotCharts.wedLabel": "水", "common.ui.management.breadcrumb": "管理", - "common.ui.management.nav.menu": "管理メニュー", "common.ui.modals.cancelButtonLabel": "キャンセル", "common.ui.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストが接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", @@ -519,6 +518,7 @@ "common.ui.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} と {lt} {to}", "management.connectDataDisplayName": "データに接続", "management.displayName": "管理", + "management.nav.menu": "管理メニュー", "management.editIndexPattern.createIndex.defaultButtonDescription": "すべてのデータに完全集約を実行", "management.editIndexPattern.createIndex.defaultButtonText": "標準インデックスパターン", "management.editIndexPattern.createIndex.defaultTypeName": "インデックスパターン", @@ -970,8 +970,8 @@ "kibana-react.savedObjects.saveModal.saveButtonLabel": "保存", "kibana-react.savedObjects.saveModal.saveTitle": "{objectType} を保存", "kibana-react.savedObjects.saveModal.titleLabel": "タイトル", - "kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", - "kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", "inspector.closeButton": "インスペクターを閉じる", "inspector.reqTimestampDescription": "リクエストの開始が記録された時刻です", "inspector.reqTimestampKey": "リクエストのタイムスタンプ", @@ -8048,7 +8048,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel": "異常スコア", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel": "下の境界", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel": "上の境界", - "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel": "値", "xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel": "{numberOfCauses} 個の {plusSign}異常な{byFieldName}値", "xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel": "複数バケットの影響", "xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel": "予定イベント {counter}", @@ -10966,7 +10965,6 @@ "xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel": "サーバー側エコシステム", "xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel": "ストレージクラス", "xpack.snapshotRestore.repositoryDetails.typeTitle": "レポジトリタイプ", - "xpack.snapshotRestore.repositoryDetails.verificationDetails": "認証情報レポジトリ「{name}」", "xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle": "詳細", "xpack.snapshotRestore.repositoryDetails.verificationTitle": "認証ステータス", "xpack.snapshotRestore.repositoryDetails.verifyButtonLabel": "レポジトリを検証", @@ -11739,7 +11737,6 @@ "xpack.uptime.emptyState.loadingMessage": "読み込み中…", "xpack.uptime.emptyState.noDataTitle": "利用可能なアップタイムデータがありません", "xpack.uptime.emptyStateError.title": "エラー", - "xpack.uptime.emptyStatusBar.defaultMessage": "監視 ID {monitorId} のデータが見つかりません", "xpack.uptime.errorMessage": "エラー: {message}", "xpack.uptime.featureCatalogueDescription": "エンドポイントヘルスチェックとアップタイム監視を行います。", "xpack.uptime.featureRegistry.uptimeFeatureName": "アップタイム", @@ -11779,7 +11776,6 @@ "xpack.uptime.monitorList.statusColumn.upLabel": "アップ", "xpack.uptime.monitorList.statusColumnLabel": "ステータス", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "ミリ秒単位の監視時間", - "xpack.uptime.monitorStatusBar.healthStatus.durationInMillisecondsMessage": "{duration}ms", "xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel": "ダウン", "xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel": "アップ", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "監視ステータス", @@ -11816,9 +11812,9 @@ "xpack.uptime.pingList.expandRow": "拡張", "xpack.uptime.snapshot.pingsOverTimeTitle": "一定時間のピング", "xpack.uptime.snapshotHistogram.yAxis.title": "ピング", - "xpack.uptime.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total} 個中 {down} 個のモニターがダウンしています。", - "xpack.uptime.donutChart.legend.downRowLabel": "ダウン", - "xpack.uptime.donutChart.legend.upRowLabel": "アップ", + "xpack.uptime.snapshot.donutChart.ariaLabel": "現在のステータスを表す円グラフ、{total} 個中 {down} 個のモニターがダウンしています。", + "xpack.uptime.snapshot.donutChart.legend.downRowLabel": "ダウン", + "xpack.uptime.snapshot.donutChart.legend.upRowLabel": "アップ", "xpack.uptime.durationChart.emptyPrompt.description": "このモニターは選択された時間範囲で一度も {emphasizedText} していません。", "xpack.uptime.durationChart.emptyPrompt.title": "利用可能な期間データがありません", "xpack.uptime.emptyStateError.notAuthorized": "アップタイムデータの表示が承認されていません。システム管理者にお問い合わせください。", @@ -12702,4 +12698,4 @@ "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新", "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e221cba874bcdb..3cc476937d4e72 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -441,7 +441,6 @@ "common.ui.flotCharts.tueLabel": "周二", "common.ui.flotCharts.wedLabel": "周三", "common.ui.management.breadcrumb": "管理", - "common.ui.management.nav.menu": "管理菜单", "common.ui.modals.cancelButtonLabel": "取消", "common.ui.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", @@ -520,6 +519,7 @@ "common.ui.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} 且 {lt} {to}", "management.connectDataDisplayName": "连接数据", "management.displayName": "管理", + "management.nav.menu": "管理菜单", "management.editIndexPattern.createIndex.defaultButtonDescription": "对任何数据执行完全聚合", "management.editIndexPattern.createIndex.defaultButtonText": "标准索引模式", "management.editIndexPattern.createIndex.defaultTypeName": "索引模式", @@ -971,8 +971,8 @@ "kibana-react.savedObjects.saveModal.saveButtonLabel": "保存", "kibana-react.savedObjects.saveModal.saveTitle": "保存 {objectType}", "kibana-react.savedObjects.saveModal.titleLabel": "标题", - "kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "无法完整还原 URL,确保使用共享功能。", - "kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "无法完整还原 URL,确保使用共享功能。", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", "inspector.closeButton": "关闭检查器", "inspector.reqTimestampDescription": "记录请求启动的时间", "inspector.reqTimestampKey": "请求时间戳", @@ -8137,7 +8137,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel": "异常分数", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel": "下边界", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel": "上边界", - "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel": "值", "xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel": "{numberOfCauses}{plusSign} 异常 {byFieldName} 值", "xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel": "多存储桶影响", "xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel": "已计划事件{counter}", @@ -11055,7 +11054,6 @@ "xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel": "服务器端加密", "xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel": "存储类", "xpack.snapshotRestore.repositoryDetails.typeTitle": "存储库类型", - "xpack.snapshotRestore.repositoryDetails.verificationDetails": "验证详情存储库“{name}”", "xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle": "详情", "xpack.snapshotRestore.repositoryDetails.verificationTitle": "验证状态", "xpack.snapshotRestore.repositoryDetails.verifyButtonLabel": "验证存储库", @@ -11828,7 +11826,6 @@ "xpack.uptime.emptyState.loadingMessage": "正在加载……", "xpack.uptime.emptyState.noDataTitle": "没有可用的运行时间数据", "xpack.uptime.emptyStateError.title": "错误", - "xpack.uptime.emptyStatusBar.defaultMessage": "未找到监测 ID {monitorId} 的数据", "xpack.uptime.errorMessage": "错误:{message}", "xpack.uptime.featureCatalogueDescription": "执行终端节点运行状况检查和运行时间监测。", "xpack.uptime.featureRegistry.uptimeFeatureName": "运行时间", @@ -11868,7 +11865,6 @@ "xpack.uptime.monitorList.statusColumn.upLabel": "运行", "xpack.uptime.monitorList.statusColumnLabel": "状态", "xpack.uptime.monitorStatusBar.durationTextAriaLabel": "监测持续时间(毫秒)", - "xpack.uptime.monitorStatusBar.healthStatus.durationInMillisecondsMessage": "{duration}ms", "xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel": "关闭", "xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel": "运行", "xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel": "检测状态", @@ -11905,9 +11901,9 @@ "xpack.uptime.pingList.expandRow": "展开", "xpack.uptime.snapshot.pingsOverTimeTitle": "时移 Ping 数", "xpack.uptime.snapshotHistogram.yAxis.title": "Ping", - "xpack.uptime.donutChart.ariaLabel": "显示当前状态的饼图。{down} 个监测已关闭,共 {total} 个。", - "xpack.uptime.donutChart.legend.downRowLabel": "关闭", - "xpack.uptime.donutChart.legend.upRowLabel": "运行", + "xpack.uptime.snapshot.donutChart.ariaLabel": "显示当前状态的饼图。{down} 个监测已关闭,共 {total} 个。", + "xpack.uptime.snapshot.donutChart.legend.downRowLabel": "关闭", + "xpack.uptime.snapshot.donutChart.legend.upRowLabel": "运行", "xpack.uptime.durationChart.emptyPrompt.description": "在选定时间范围内此监测从未{emphasizedText}。", "xpack.uptime.durationChart.emptyPrompt.title": "没有持续时间数据", "xpack.uptime.emptyStateError.notAuthorized": "您无权查看 Uptime 数据,请联系系统管理员。", @@ -12791,4 +12787,4 @@ "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可", "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期" } -} +} \ No newline at end of file diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 86db39823ba91c..bda5b51623d058 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,6 +9,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), require.resolve('../test/reporting/configs/generate_api.js'), + require.resolve('../test/functional/config_security_basic.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index ebe741df71d79f..b5d201c1682bd4 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -202,8 +202,21 @@ export default function(kibana: any) { id: 'test.always-firing', name: 'Test: Always Firing', actionGroups: ['default', 'other'], - async executor({ services, params, state }: AlertExecutorOptions) { + async executor(alertExecutorOptions: AlertExecutorOptions) { + const { + services, + params, + state, + alertId, + spaceId, + namespace, + name, + tags, + createdBy, + updatedBy, + } = alertExecutorOptions; let group = 'default'; + const alertInfo = { alertId, spaceId, namespace, name, tags, createdBy, updatedBy }; if (params.groupsToScheduleActionsInSeries) { const index = state.groupInSeriesIndex || 0; @@ -226,6 +239,7 @@ export default function(kibana: any) { params, reference: params.reference, source: 'alert:test.always-firing', + alertInfo, }, }); return { diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index c47649544f9a76..c793af359489af 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -24,6 +24,14 @@ export interface CreateAlertWithActionOpts { reference: string; } +interface UpdateAlwaysFiringAction { + alertId: string; + actionId: string | undefined; + reference: string; + user: User; + overwrites: Record; +} + export class AlertUtils { private referenceCounter = 1; private readonly user?: User; @@ -176,38 +184,41 @@ export class AlertUtils { if (this.user) { request = request.auth(this.user.username, this.user.password); } - const response = await request.send({ - enabled: true, - name: 'abc', - schedule: { interval: '1m' }, - throttle: '1m', - tags: [], - alertTypeId: 'test.always-firing', - consumer: 'bar', - params: { - index: ES_TEST_INDEX_NAME, - reference, - }, - actions: [ - { - group: 'default', - id: this.indexRecordActionId, - params: { - index: ES_TEST_INDEX_NAME, - reference, - message: - 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', - }, - }, - ], - ...overwrites, - }); + const alertBody = getDefaultAlwaysFiringAlertData(reference, actionId); + const response = await request.send({ ...alertBody, ...overwrites }); if (response.statusCode === 200) { objRemover.add(this.space.id, response.body.id, 'alert'); } return response; } + public async updateAlwaysFiringAction({ + alertId, + actionId, + reference, + user, + overwrites = {}, + }: UpdateAlwaysFiringAction) { + actionId = actionId || this.indexRecordActionId; + + if (!actionId) { + throw new Error('actionId is required '); + } + + const request = this.supertestWithoutAuth + .put(`${getUrlPrefix(this.space.id)}/api/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + const alertBody = getDefaultAlwaysFiringAlertData(reference, actionId); + delete alertBody.alertTypeId; + delete alertBody.enabled; + delete alertBody.consumer; + + const response = await request.send({ ...alertBody, ...overwrites }); + return response; + } + public async createAlwaysFailingAction({ objectRemover, overwrites = {}, @@ -251,3 +262,31 @@ export class AlertUtils { return response; } } + +function getDefaultAlwaysFiringAlertData(reference: string, actionId: string) { + return { + enabled: true, + name: 'abc', + schedule: { interval: '1m' }, + throttle: '1m', + tags: [], + alertTypeId: 'test.always-firing', + consumer: 'bar', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + actions: [ + { + group: 'default', + id: actionId, + params: { + index: ES_TEST_INDEX_NAME, + reference, + message: + 'instanceContextValue: {{context.instanceContextValue}}, instanceStateValue: {{state.instanceStateValue}}', + }, + }, + ], + }; +} diff --git a/x-pack/test/alerting_api_integration/common/types.ts b/x-pack/test/alerting_api_integration/common/types.ts index e94add5bbcd288..c4a341435aaaa5 100644 --- a/x-pack/test/alerting_api_integration/common/types.ts +++ b/x-pack/test/alerting_api_integration/common/types.ts @@ -52,6 +52,7 @@ export interface User { export interface Space { id: string; + namespace?: string; name: string; disabledFeatures: string[]; } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts index 354d87bd11bb2d..d58fcd29e29fce 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts @@ -29,7 +29,7 @@ const NoKibanaPrivileges: User = { }, }; -const Superuser: User = { +export const Superuser: User = { username: 'superuser', fullName: 'superuser', password: 'superuser-password', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 69bc547e3bfc11..551498e22d5c8c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { UserAtSpaceScenarios } from '../../scenarios'; +import { UserAtSpaceScenarios, Superuser } from '../../scenarios'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ESTestIndexTool, @@ -96,7 +96,9 @@ export default function alertTests({ getService }: FtrProviderContext) { // Wait for the action to index a document before disabling the alert and waiting for tasks to finish await esTestIndexTool.waitForDocs('action:test.index-record', reference); - await alertUtils.disable(response.body.id); + + const alertId = response.body.id; + await alertUtils.disable(alertId); await taskManagerUtils.waitForIdle(testStart); // Ensure only 1 alert executed with proper params @@ -113,6 +115,15 @@ export default function alertTests({ getService }: FtrProviderContext) { index: ES_TEST_INDEX_NAME, reference, }, + alertInfo: { + alertId, + spaceId: space.id, + namespace: space.id, + name: 'abc', + tags: [], + createdBy: user.fullName, + updatedBy: user.fullName, + }, }); // Ensure only 1 action executed with proper params @@ -142,6 +153,56 @@ export default function alertTests({ getService }: FtrProviderContext) { } }); + it('should pass updated alert params to executor', async () => { + // create an alert + const reference = alertUtils.generateReference(); + const overwrites = { + throttle: '1s', + schedule: { interval: '1s' }, + }; + const response = await alertUtils.createAlwaysFiringAction({ reference, overwrites }); + + // only need to test creation success paths + if (response.statusCode !== 200) return; + + // update the alert with super user + const alertId = response.body.id; + const reference2 = alertUtils.generateReference(); + const response2 = await alertUtils.updateAlwaysFiringAction({ + alertId, + actionId: indexRecordActionId, + user: Superuser, + reference: reference2, + overwrites: { + name: 'def', + tags: ['fee', 'fi', 'fo'], + throttle: '1s', + schedule: { interval: '1s' }, + }, + }); + + expect(response2.statusCode).to.eql(200); + + // make sure alert info passed to executor is correct + await esTestIndexTool.waitForDocs('alert:test.always-firing', reference2); + await alertUtils.disable(alertId); + const alertSearchResult = await esTestIndexTool.search( + 'alert:test.always-firing', + reference2 + ); + + expect(alertSearchResult.hits.total.value).to.be.greaterThan(0); + expect(alertSearchResult.hits.hits[0]._source.alertInfo).to.eql({ + alertId, + spaceId: space.id, + namespace: space.id, + name: 'def', + tags: ['fee', 'fi', 'fo'], + createdBy: user.fullName, + updatedBy: Superuser.fullName, + }); + }); + it('should handle custom retry logic when appropriate', async () => { const testStart = new Date(); // We have to provide the test.rate-limit the next runAt, for testing purposes diff --git a/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts b/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts index 2b91c408d3de91..c2b3ec6148036e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/scenarios.ts @@ -8,17 +8,27 @@ import { Space } from '../common/types'; const Space1: Space = { id: 'space1', + namespace: 'space1', name: 'Space 1', disabledFeatures: [], }; const Other: Space = { id: 'other', + namespace: 'other', name: 'Other', disabledFeatures: [], }; +const Default: Space = { + id: 'default', + namespace: undefined, + name: 'Default', + disabledFeatures: [], +}; + export const Spaces = { space1: Space1, other: Other, + default: Default, }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts similarity index 88% rename from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts rename to x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts index 032fee15882cf8..d9a58851afb31a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_base.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { Response as SupertestResponse } from 'supertest'; -import { Spaces } from '../../scenarios'; +import { Space } from '../../../common/types'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ESTestIndexTool, @@ -19,7 +19,7 @@ import { } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export -export default function alertTests({ getService }: FtrProviderContext) { +export function alertTests({ getService }: FtrProviderContext, space: Space) { const supertestWithoutAuth = getService('supertestWithoutAuth'); const es = getService('legacyEs'); const retry = getService('retry'); @@ -43,7 +43,7 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexTool.setup(); await es.indices.create({ index: authorizationIndex }); const { body: createdAction } = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .post(`${getUrlPrefix(space.id)}/api/action`) .set('kbn-xsrf', 'foo') .send({ name: 'My action', @@ -58,7 +58,7 @@ export default function alertTests({ getService }: FtrProviderContext) { .expect(200); indexRecordActionId = createdAction.id; alertUtils = new AlertUtils({ - space: Spaces.space1, + space, supertestWithoutAuth, indexRecordActionId, objectRemover, @@ -68,19 +68,20 @@ export default function alertTests({ getService }: FtrProviderContext) { after(async () => { await esTestIndexTool.destroy(); await es.indices.delete({ index: authorizationIndex }); - objectRemover.add(Spaces.space1.id, indexRecordActionId, 'action'); + objectRemover.add(space.id, indexRecordActionId, 'action'); await objectRemover.removeAll(); }); it('should schedule task, run alert and schedule actions', async () => { const reference = alertUtils.generateReference(); const response = await alertUtils.createAlwaysFiringAction({ reference }); + const alertId = response.body.id; expect(response.statusCode).to.eql(200); const alertTestRecord = ( await esTestIndexTool.waitForDocs('alert:test.always-firing', reference) )[0]; - expect(alertTestRecord._source).to.eql({ + const expected = { source: 'alert:test.always-firing', reference, state: {}, @@ -88,7 +89,20 @@ export default function alertTests({ getService }: FtrProviderContext) { index: ES_TEST_INDEX_NAME, reference, }, - }); + alertInfo: { + alertId, + spaceId: space.id, + namespace: space.namespace, + name: 'abc', + tags: [], + createdBy: null, + updatedBy: null, + }, + }; + if (expected.alertInfo.namespace === undefined) { + delete expected.alertInfo.namespace; + } + expect(alertTestRecord._source).to.eql(expected); const actionTestRecord = ( await esTestIndexTool.waitForDocs('action:test.index-record', reference) )[0]; @@ -147,7 +161,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const retryDate = new Date(Date.now() + 60000); const { body: createdAction } = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .post(`${getUrlPrefix(space.id)}/api/action`) .set('kbn-xsrf', 'foo') .send({ name: 'Test rate limit', @@ -155,11 +169,11 @@ export default function alertTests({ getService }: FtrProviderContext) { config: {}, }) .expect(200); - objectRemover.add(Spaces.space1.id, createdAction.id, 'action'); + objectRemover.add(space.id, createdAction.id, 'action'); const reference = alertUtils.generateReference(); const response = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .post(`${getUrlPrefix(space.id)}/api/alert`) .set('kbn-xsrf', 'foo') .send( getTestAlertData({ @@ -184,7 +198,7 @@ export default function alertTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).to.eql(200); - objectRemover.add(Spaces.space1.id, response.body.id, 'alert'); + objectRemover.add(space.id, response.body.id, 'alert'); const scheduledActionTask = await retry.try(async () => { const searchResult = await es.search({ index: '.kibana_task_manager', @@ -228,7 +242,7 @@ export default function alertTests({ getService }: FtrProviderContext) { it('should have proper callCluster and savedObjectsClient authorization for alert type executor', async () => { const reference = alertUtils.generateReference(); const response = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .post(`${getUrlPrefix(space.id)}/api/alert`) .set('kbn-xsrf', 'foo') .send( getTestAlertData({ @@ -244,7 +258,7 @@ export default function alertTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).to.eql(200); - objectRemover.add(Spaces.space1.id, response.body.id, 'alert'); + objectRemover.add(space.id, response.body.id, 'alert'); const alertTestRecord = ( await esTestIndexTool.waitForDocs('alert:test.authorization', reference) )[0]; @@ -264,16 +278,16 @@ export default function alertTests({ getService }: FtrProviderContext) { it('should have proper callCluster and savedObjectsClient authorization for action type executor', async () => { const reference = alertUtils.generateReference(); const { body: createdAction } = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/action`) + .post(`${getUrlPrefix(space.id)}/api/action`) .set('kbn-xsrf', 'foo') .send({ name: 'My action', actionTypeId: 'test.authorization', }) .expect(200); - objectRemover.add(Spaces.space1.id, createdAction.id, 'action'); + objectRemover.add(space.id, createdAction.id, 'action'); const response = await supertestWithoutAuth - .post(`${getUrlPrefix(Spaces.space1.id)}/api/alert`) + .post(`${getUrlPrefix(space.id)}/api/alert`) .set('kbn-xsrf', 'foo') .send( getTestAlertData({ @@ -299,7 +313,7 @@ export default function alertTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).to.eql(200); - objectRemover.add(Spaces.space1.id, response.body.id, 'alert'); + objectRemover.add(space.id, response.body.id, 'alert'); const actionTestRecord = ( await esTestIndexTool.waitForDocs('action:test.authorization', reference) )[0]; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.ts new file mode 100644 index 00000000000000..3e677952d87006 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_default_space.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { Spaces } from '../../scenarios'; +import { alertTests } from './alerts_base'; + +// eslint-disable-next-line import/no-default-export +export default function alertSpace1Tests(context: FtrProviderContext) { + alertTests(context, Spaces.default); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.ts new file mode 100644 index 00000000000000..07ad4cd294ab39 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts_space1.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { Spaces } from '../../scenarios'; +import { alertTests } from './alerts_base'; + +// eslint-disable-next-line import/no-default-export +export default function alertSpace1Tests(context: FtrProviderContext) { + alertTests(context, Spaces.space1); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 1aa084356cfa49..569c0d538d473f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -22,6 +22,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./unmute_instance')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_api_key')); - loadTestFile(require.resolve('./alerts')); + loadTestFile(require.resolve('./alerts_space1')); + loadTestFile(require.resolve('./alerts_default_space')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts index f18aebaf4e6897..cb2b17980d37ae 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/index.ts @@ -20,7 +20,10 @@ export default function alertingApiIntegrationTests({ before(async () => { for (const space of Object.values(Spaces)) { - await spacesService.create(space); + if (space.id === 'default') continue; + + const { id, name, disabledFeatures } = space; + await spacesService.create({ id, name, disabledFeatures }); } }); diff --git a/x-pack/test/api_integration/apis/endpoint/endpoints.ts b/x-pack/test/api_integration/apis/endpoint/endpoints.ts index 95c3678672da30..32864489d37867 100644 --- a/x-pack/test/api_integration/apis/endpoint/endpoints.ts +++ b/x-pack/test/api_integration/apis/endpoint/endpoints.ts @@ -10,9 +10,24 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); describe('test endpoints api', () => { - before(() => esArchiver.load('endpoint/endpoints')); - after(() => esArchiver.unload('endpoint/endpoints')); - describe('GET /api/endpoint/endpoints', () => { + describe('POST /api/endpoint/endpoints when index is empty', () => { + it('endpoints api should return empty result when index is empty', async () => { + await esArchiver.unload('endpoint/endpoints'); + const { body } = await supertest + .post('/api/endpoint/endpoints') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(0); + expect(body.endpoints.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_index).to.eql(0); + }); + }); + + describe('POST /api/endpoint/endpoints when index is not empty', () => { + before(() => esArchiver.load('endpoint/endpoints')); + after(() => esArchiver.unload('endpoint/endpoints')); it('endpoints api should return one entry for each endpoint with default paging', async () => { const { body } = await supertest .post('/api/endpoint/endpoints') @@ -46,6 +61,30 @@ export default function({ getService }: FtrProviderContext) { expect(body.request_index).to.eql(1); }); + /* test that when paging properties produces no result, the total should reflect the actual number of endpoints + in the index. + */ + it('endpoints api should return accurate total endpoints if page index produces no result', async () => { + const { body } = await supertest + .post('/api/endpoint/endpoints') + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 3, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(3); + expect(body.endpoints.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_index).to.eql(30); + }); + it('endpoints api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest .post('/api/endpoint/endpoints') diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json index 04d56d5949d2c7..65094144d6ff0d 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json @@ -9,21 +9,6 @@ "totalShards": 10, "status": "green" }, - "logs": { - "enabled": false, - "limit": 10, - "reason": { - "clusterExists": false, - "indexPatternExists": false, - "indexPatternInTimeRangeExists": false, - "typeExistsAtAnyTime": false, - "usingStructuredLogs": false, - "nodeExists": null, - "indexExists": false, - "typeExists": false - }, - "logs": [] - }, "metrics": { "index_search_request_rate": [ { @@ -1104,93 +1089,108 @@ } ] }, + "logs": { + "enabled": false, + "logs": [], + "reason": { + "indexPatternExists": false, + "indexPatternInTimeRangeExists": false, + "typeExistsAtAnyTime": false, + "typeExists": false, + "usingStructuredLogs": false, + "clusterExists": false, + "nodeExists": null, + "indexExists": false + }, + "limit": 10 + }, "shards": [ { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 4, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 4, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": true, "relocating_node": null, "shard": 1, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, "relocating_node": null, "shard": 1, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": true, "relocating_node": null, "shard": 2, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, "relocating_node": null, "shard": 2, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 3, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 3, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 0, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 0, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" } ], "shardStats": { "nodes": { "jUT5KdxfRbORSCWkb5zjmA": { - "shardCount": 38, - "indexCount": 20, + "shardCount": 5, + "indexCount": 1, "name": "whatever-01", "node_ids": [ "jUT5KdxfRbORSCWkb5zjmA" @@ -1198,29 +1198,20 @@ "type": "master" }, "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, + "shardCount": 5, + "indexCount": 1, "name": "whatever-02", "node_ids": [ "xcP6ue7eRCieNNitFTT0EA" ], "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": [ - "bwQWH-7IQY-mFPpfoaoFXQ" - ], - "type": "node" } } }, "nodes": { "jUT5KdxfRbORSCWkb5zjmA": { - "shardCount": 38, - "indexCount": 20, + "shardCount": 5, + "indexCount": 1, "name": "whatever-01", "node_ids": [ "jUT5KdxfRbORSCWkb5zjmA" @@ -1228,22 +1219,13 @@ "type": "master" }, "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, + "shardCount": 5, + "indexCount": 1, "name": "whatever-02", "node_ids": [ "xcP6ue7eRCieNNitFTT0EA" ], "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": [ - "bwQWH-7IQY-mFPpfoaoFXQ" - ], - "type": "node" } }, "stateUuid": "6wwwErXyTfaa4uHBHG5Pbg" diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json index 0b8d26558e7fc5..32096b0b970674 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json @@ -1,293 +1,1518 @@ { "nodeSummary": { - "resolver": "jxcP6ue7eRCieNNitFTT0EA", - "node_ids": [], - "attributes": {}, - "transport_address": "", - "name": "jxcP6ue7eRCieNNitFTT0EA", - "type": "node", - "nodeTypeLabel": "Offline Node", - "status": "Offline", - "isOnline": false + "resolver": "jUT5KdxfRbORSCWkb5zjmA", + "node_ids": [ + "jUT5KdxfRbORSCWkb5zjmA" + ], + "attributes": { + "ml.enabled": "true", + "ml.max_open_jobs": "10" + }, + "transport_address": "127.0.0.1:9300", + "name": "whatever-01", + "type": "master", + "nodeTypeLabel": "Master Node", + "nodeTypeClass": "starFilled", + "totalShards": 38, + "indexCount": 20, + "documents": 24830, + "dataSize": 52847579, + "freeSpace": 186755088384, + "totalSpace": 499065712640, + "usedHeap": 29, + "status": "Online", + "isOnline": true + }, + "metrics": { + "node_total_io": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.operations", + "metricAgg": "max", + "label": "Total I/O", + "title": "I/O Operations Rate", + "description": "Total I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.read_operations", + "metricAgg": "max", + "label": "Total Read I/O", + "title": "I/O Operations Rate", + "description": "Total Read I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.write_operations", + "metricAgg": "max", + "label": "Total Write I/O", + "title": "I/O Operations Rate", + "description": "Total Write I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + } + ], + "node_latency": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.search.query_total", + "metricAgg": "sum", + "label": "Search", + "title": "Latency", + "description": "Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.", + "units": "ms", + "format": "0,0.[00]", + "hasCalculation": true, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + 0.33333333333333337 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 0.33333333333333337 + ], + [ + 1507235560000, + 0 + ], + [ + 1507235570000, + 0.33333333333333337 + ], + [ + 1507235580000, + 0 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0.33333333333333337 + ], + [ + 1507235610000, + 0 + ], + [ + 1507235620000, + 0 + ], + [ + 1507235630000, + 0.33333333333333337 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 0 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 0.2 + ], + [ + 1507235680000, + 0 + ], + [ + 1507235690000, + 0 + ], + [ + 1507235700000, + 0 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.indexing.index_total", + "metricAgg": "sum", + "label": "Indexing", + "title": "Latency", + "description": "Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This considers any shard located on this node, including replicas.", + "units": "ms", + "format": "0,0.[00]", + "hasCalculation": true, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + 0 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 0.888888888888889 + ], + [ + 1507235560000, + 1.1666666666666667 + ], + [ + 1507235570000, + 0 + ], + [ + 1507235580000, + 0 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0 + ], + [ + 1507235610000, + 1.3333333333333333 + ], + [ + 1507235620000, + 1.1666666666666667 + ], + [ + 1507235630000, + 0 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 0 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 2.3333333333333335 + ], + [ + 1507235680000, + 2.8749999999999996 + ], + [ + 1507235690000, + 0 + ], + [ + 1507235700000, + 0 + ] + ] + } + ], + "node_jvm_mem": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.jvm.mem.heap_max_in_bytes", + "metricAgg": "max", + "label": "Max Heap", + "title": "JVM Heap", + "description": "Total heap available to Elasticsearch running in the JVM.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 709623808 + ], + [ + 1507235530000, + 709623808 + ], + [ + 1507235540000, + 709623808 + ], + [ + 1507235550000, + 709623808 + ], + [ + 1507235560000, + 709623808 + ], + [ + 1507235570000, + 709623808 + ], + [ + 1507235580000, + 709623808 + ], + [ + 1507235590000, + 709623808 + ], + [ + 1507235600000, + 709623808 + ], + [ + 1507235610000, + 709623808 + ], + [ + 1507235620000, + 709623808 + ], + [ + 1507235630000, + 709623808 + ], + [ + 1507235640000, + 709623808 + ], + [ + 1507235650000, + 709623808 + ], + [ + 1507235660000, + 709623808 + ], + [ + 1507235670000, + 709623808 + ], + [ + 1507235680000, + 709623808 + ], + [ + 1507235690000, + 709623808 + ], + [ + 1507235700000, + 709623808 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.jvm.mem.heap_used_in_bytes", + "metricAgg": "max", + "label": "Used Heap", + "title": "JVM Heap", + "description": "Total heap used by Elasticsearch running in the JVM.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 317052776 + ], + [ + 1507235530000, + 344014976 + ], + [ + 1507235540000, + 368593248 + ], + [ + 1507235550000, + 253850400 + ], + [ + 1507235560000, + 348095032 + ], + [ + 1507235570000, + 182919712 + ], + [ + 1507235580000, + 212395016 + ], + [ + 1507235590000, + 244004144 + ], + [ + 1507235600000, + 270412240 + ], + [ + 1507235610000, + 245052864 + ], + [ + 1507235620000, + 370270616 + ], + [ + 1507235630000, + 196944168 + ], + [ + 1507235640000, + 223491760 + ], + [ + 1507235650000, + 253878472 + ], + [ + 1507235660000, + 280811736 + ], + [ + 1507235670000, + 371931976 + ], + [ + 1507235680000, + 329874616 + ], + [ + 1507235690000, + 363869776 + ], + [ + 1507235700000, + 211045968 + ] + ] + } + ], + "node_mem": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.memory_in_bytes", + "metricAgg": "max", + "label": "Lucene Total", + "title": "Index Memory", + "description": "Total heap memory used by Lucene for current index. This is the sum of other fields for primary and replica shards on this node.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 4797457 + ], + [ + 1507235530000, + 4797457 + ], + [ + 1507235540000, + 4797457 + ], + [ + 1507235550000, + 4797457 + ], + [ + 1507235560000, + 4823580 + ], + [ + 1507235570000, + 4823580 + ], + [ + 1507235580000, + 4823580 + ], + [ + 1507235590000, + 4823580 + ], + [ + 1507235600000, + 4823580 + ], + [ + 1507235610000, + 4838368 + ], + [ + 1507235620000, + 4741420 + ], + [ + 1507235630000, + 4741420 + ], + [ + 1507235640000, + 4741420 + ], + [ + 1507235650000, + 4741420 + ], + [ + 1507235660000, + 4741420 + ], + [ + 1507235670000, + 4757998 + ], + [ + 1507235680000, + 4787542 + ], + [ + 1507235690000, + 4787542 + ], + [ + 1507235700000, + 4787542 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.terms_memory_in_bytes", + "metricAgg": "max", + "label": "Terms", + "title": "Index Memory", + "description": "Heap memory used by Terms (e.g., text). This is a part of Lucene Total.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 3764438 + ], + [ + 1507235530000, + 3764438 + ], + [ + 1507235540000, + 3764438 + ], + [ + 1507235550000, + 3764438 + ], + [ + 1507235560000, + 3786762 + ], + [ + 1507235570000, + 3786762 + ], + [ + 1507235580000, + 3786762 + ], + [ + 1507235590000, + 3786762 + ], + [ + 1507235600000, + 3786762 + ], + [ + 1507235610000, + 3799306 + ], + [ + 1507235620000, + 3715996 + ], + [ + 1507235630000, + 3715996 + ], + [ + 1507235640000, + 3715996 + ], + [ + 1507235650000, + 3715996 + ], + [ + 1507235660000, + 3715996 + ], + [ + 1507235670000, + 3729890 + ], + [ + 1507235680000, + 3755528 + ], + [ + 1507235690000, + 3755528 + ], + [ + 1507235700000, + 3755528 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.points_memory_in_bytes", + "metricAgg": "max", + "label": "Points", + "title": "Index Memory", + "description": "Heap memory used by Points (e.g., numbers, IPs, and geo data). This is a part of Lucene Total.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 12171 + ], + [ + 1507235530000, + 12171 + ], + [ + 1507235540000, + 12171 + ], + [ + 1507235550000, + 12171 + ], + [ + 1507235560000, + 12198 + ], + [ + 1507235570000, + 12198 + ], + [ + 1507235580000, + 12198 + ], + [ + 1507235590000, + 12198 + ], + [ + 1507235600000, + 12198 + ], + [ + 1507235610000, + 12218 + ], + [ + 1507235620000, + 12120 + ], + [ + 1507235630000, + 12120 + ], + [ + 1507235640000, + 12120 + ], + [ + 1507235650000, + 12120 + ], + [ + 1507235660000, + 12120 + ], + [ + 1507235670000, + 12140 + ], + [ + 1507235680000, + 12166 + ], + [ + 1507235690000, + 12166 + ], + [ + 1507235700000, + 12166 + ] + ] + } + ], + "node_cpu_metric": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.process.cpu.percent", + "metricAgg": "max", + "label": "CPU Utilization", + "description": "Percentage of CPU usage for the Elasticsearch process.", + "units": "%", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 1 + ], + [ + 1507235530000, + 0 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 1 + ], + [ + 1507235560000, + 2 + ], + [ + 1507235570000, + 0 + ], + [ + 1507235580000, + 2 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0 + ], + [ + 1507235610000, + 3 + ], + [ + 1507235620000, + 2 + ], + [ + 1507235630000, + 2 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 1 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 2 + ], + [ + 1507235680000, + 2 + ], + [ + 1507235690000, + 1 + ], + [ + 1507235700000, + 0 + ] + ] + } + ], + "node_load_average": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.os.cpu.load_average.1m", + "metricAgg": "max", + "label": "1m", + "title": "System Load", + "description": "Load average over the last minute.", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 2.876953125 + ], + [ + 1507235530000, + 2.66015625 + ], + [ + 1507235540000, + 2.40625 + ], + [ + 1507235550000, + 2.189453125 + ], + [ + 1507235560000, + 2.626953125 + ], + [ + 1507235570000, + 2.451171875 + ], + [ + 1507235580000, + 2.81640625 + ], + [ + 1507235590000, + 3.70703125 + ], + [ + 1507235600000, + 3.51171875 + ], + [ + 1507235610000, + 3.359375 + ], + [ + 1507235620000, + 3.076171875 + ], + [ + 1507235630000, + 2.990234375 + ], + [ + 1507235640000, + 2.904296875 + ], + [ + 1507235650000, + 2.84375 + ], + [ + 1507235660000, + 3.28125 + ], + [ + 1507235670000, + 5.30859375 + ], + [ + 1507235680000, + 7.63671875 + ], + [ + 1507235690000, + 9.4375 + ], + [ + 1507235700000, + 11.421875 + ] + ] + } + ], + "node_segment_count": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.count", + "metricAgg": "max", + "label": "Segment Count", + "description": "Maximum segment count for primary and replica shards on this node.", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 128 + ], + [ + 1507235530000, + 128 + ], + [ + 1507235540000, + 128 + ], + [ + 1507235550000, + 128 + ], + [ + 1507235560000, + 131 + ], + [ + 1507235570000, + 131 + ], + [ + 1507235580000, + 131 + ], + [ + 1507235590000, + 131 + ], + [ + 1507235600000, + 131 + ], + [ + 1507235610000, + 133 + ], + [ + 1507235620000, + 126 + ], + [ + 1507235630000, + 126 + ], + [ + 1507235640000, + 126 + ], + [ + 1507235650000, + 126 + ], + [ + 1507235660000, + 126 + ], + [ + 1507235670000, + 128 + ], + [ + 1507235680000, + 130 + ], + [ + 1507235690000, + 130 + ], + [ + 1507235700000, + 130 + ] + ] + } + ] }, "logs": { "enabled": false, - "limit": 10, + "logs": [], "reason": { - "clusterExists": false, "indexPatternExists": false, "indexPatternInTimeRangeExists": false, "typeExistsAtAnyTime": false, + "typeExists": false, "usingStructuredLogs": false, + "clusterExists": false, "nodeExists": false, - "indexExists": null, - "typeExists": false + "indexExists": null }, - "logs": [] + "limit": 10 }, - "metrics": { - "node_latency": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.search.query_total", - "metricAgg": "sum", - "label": "Search", - "title": "Latency", - "description": "Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.", - "units": "ms", - "format": "0,0.[00]", - "hasCalculation": true, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.indexing.index_total", - "metricAgg": "sum", - "label": "Indexing", - "title": "Latency", - "description": "Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This considers any shard located on this node, including replicas.", - "units": "ms", - "format": "0,0.[00]", - "hasCalculation": true, - "isDerivative": false - }, - "data": [] - }], - "node_jvm_mem": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.jvm.mem.heap_max_in_bytes", - "metricAgg": "max", - "label": "Max Heap", - "title": "JVM Heap", - "description": "Total heap available to Elasticsearch running in the JVM.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.jvm.mem.heap_used_in_bytes", - "metricAgg": "max", - "label": "Used Heap", - "title": "JVM Heap", - "description": "Total heap used by Elasticsearch running in the JVM.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_mem": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.memory_in_bytes", - "metricAgg": "max", - "label": "Lucene Total", - "title": "Index Memory", - "description": "Total heap memory used by Lucene for current index. This is the sum of other fields for primary and replica shards on this node.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.terms_memory_in_bytes", - "metricAgg": "max", - "label": "Terms", - "title": "Index Memory", - "description": "Heap memory used by Terms (e.g., text). This is a part of Lucene Total.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.points_memory_in_bytes", - "metricAgg": "max", - "label": "Points", - "title": "Index Memory", - "description": "Heap memory used by Points (e.g., numbers, IPs, and geo data). This is a part of Lucene Total.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_cpu_metric": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.process.cpu.percent", - "metricAgg": "max", - "label": "CPU Utilization", - "description": "Percentage of CPU usage for the Elasticsearch process.", - "units": "%", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_total_io": [{ - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } - }, - { - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total Read I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.read_operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total Read I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } + "shards": [ + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" }, { - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total Write I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.write_operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total Write I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } - }], - "node_load_average": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.os.cpu.load_average.1m", - "metricAgg": "max", - "label": "1m", - "title": "System Load", - "description": "Load average over the last minute.", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_segment_count": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.count", - "metricAgg": "max", - "label": "Segment Count", - "description": "Maximum segment count for primary and replica shards on this node.", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }] - }, - "shards": [], + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "relocation_test", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": "bwQWH-7IQY-mFPpfoaoFXQ", + "shard": 0, + "state": "RELOCATING" + } + ], "shardStats": { "indices": { "avocado-tweets-2017.09.30": { "status": "green", - "primary": 5, - "replica": 5, + "primary": 3, + "replica": 2, "unassigned": { "primary": 0, "replica": 0 @@ -295,8 +1520,8 @@ }, "avocado-tweets-2017.10.02": { "status": "green", - "primary": 5, - "replica": 5, + "primary": 3, + "replica": 2, "unassigned": { "primary": 0, "replica": 0 @@ -305,43 +1530,34 @@ "avocado-tweets-2017.10.03": { "status": "green", "primary": 5, - "replica": 5, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 } }, "phone-home": { - "status": "yellow", - "primary": 5, - "replica": 4, + "status": "green", + "primary": 4, + "replica": 0, "unassigned": { "primary": 0, - "replica": 1 + "replica": 0 } }, "watermelon-tweets-2017.10.05": { - "status": "yellow", - "primary": 5, - "replica": 4, - "unassigned": { - "primary": 0, - "replica": 1 - } - }, - ".security-v6": { - "status": "yellow", - "primary": 1, - "replica": 1, + "status": "green", + "primary": 4, + "replica": 0, "unassigned": { "primary": 0, - "replica": 1 + "replica": 0 } }, ".kibana": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -349,7 +1565,7 @@ }, ".monitoring-alerts-6": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -358,7 +1574,7 @@ }, ".monitoring-es-6-2017.10.05": { "status": "yellow", - "primary": 1, + "primary": 0, "replica": 0, "unassigned": { "primary": 0, @@ -367,17 +1583,26 @@ }, ".monitoring-kibana-6-2017.10.05": { "status": "yellow", - "primary": 1, + "primary": 0, "replica": 0, "unassigned": { "primary": 0, "replica": 1 } }, + ".security-v6": { + "status": "green", + "primary": 1, + "replica": 0, + "unassigned": { + "primary": 0, + "replica": 0 + } + }, ".triggered_watches": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -386,7 +1611,7 @@ ".watcher-history-7-2017.09.29": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -395,7 +1620,7 @@ ".watcher-history-7-2017.09.30": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -404,7 +1629,7 @@ ".watcher-history-7-2017.10.01": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -412,7 +1637,7 @@ }, ".watcher-history-7-2017.10.02": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -422,7 +1647,7 @@ ".watcher-history-7-2017.10.03": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -430,7 +1655,7 @@ }, ".watcher-history-7-2017.10.04": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -440,7 +1665,7 @@ ".watcher-history-7-2017.10.05": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -449,7 +1674,7 @@ ".watches": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -471,22 +1696,10 @@ "shardCount": 38, "indexCount": 20, "name": "whatever-01", - "node_ids": ["jUT5KdxfRbORSCWkb5zjmA"], + "node_ids": [ + "jUT5KdxfRbORSCWkb5zjmA" + ], "type": "master" - }, - "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, - "name": "whatever-02", - "node_ids": ["xcP6ue7eRCieNNitFTT0EA"], - "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": ["bwQWH-7IQY-mFPpfoaoFXQ"], - "type": "node" } }, "stateUuid": "6wwwErXyTfaa4uHBHG5Pbg" diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js b/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js index 43b5f6d119d6bc..9fbf1e02c9c2ec 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js @@ -32,7 +32,7 @@ export default function({ getService }) { it('should summarize node with metrics', async () => { const { body } = await supertest .post( - '/api/monitoring/v1/clusters/YCxj-RAgSZCP6GuOQ8M1EQ/elasticsearch/nodes/jxcP6ue7eRCieNNitFTT0EA' + '/api/monitoring/v1/clusters/YCxj-RAgSZCP6GuOQ8M1EQ/elasticsearch/nodes/jUT5KdxfRbORSCWkb5zjmA' ) .set('kbn-xsrf', 'xxx') .send({ diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js b/x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js new file mode 100644 index 00000000000000..4c32e311c6cf36 --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/ccs.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function({ getService }) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('ccs', () => { + const archive = 'monitoring/setup/collection/detect_apm'; + const timeRange = { + min: '2019-04-16T00:00:00.741Z', + max: '2019-04-16T23:59:59.741Z', + }; + + before('load archive', () => { + return esArchiver.load(archive); + }); + + after('unload archive', () => { + return esArchiver.unload(archive); + }); + + it('should not fail with a ccs parameter for cluster', async () => { + await supertest + .post('/api/monitoring/v1/setup/collection/cluster?skipLiveData=true') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, ccs: '*' }) + .expect(200); + }); + + it('should not fail with a ccs parameter for node', async () => { + await supertest + .post('/api/monitoring/v1/setup/collection/node/123?skipLiveData=true') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, ccs: '*' }) + .expect(200); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js b/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js index 48d8b15ecbcad0..01594babbc2f42 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/index.js @@ -16,5 +16,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./detect_logstash_management')); loadTestFile(require.resolve('./detect_apm')); loadTestFile(require.resolve('./security')); + loadTestFile(require.resolve('./ccs')); }); } diff --git a/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js b/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js deleted file mode 100644 index c21a602d7ba1a3..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { expectFixtureEql } from './helpers/expect_fixture_eql'; -import { filterBarQueryString } from '../../../../../legacy/plugins/uptime/public/queries'; - -export default function({ getService }) { - describe('filterBar query', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('returns the expected filters', async () => { - const getFilterBarQuery = { - operationName: 'FilterBar', - query: filterBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getFilterBarQuery }); - expectFixtureEql(data, 'filter_list'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json deleted file mode 100644 index f07e4163221057..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "filterBar": { - "ids": [ - "0000-intermittent", - "0001-up", - "0002-up", - "0003-up", - "0004-up", - "0005-up", - "0006-up", - "0007-up", - "0008-up", - "0009-up", - "0010-down", - "0011-up", - "0012-up", - "0013-up", - "0014-up", - "0015-intermittent", - "0016-up", - "0017-up", - "0018-up", - "0019-up" - ], - "locations": [ - "mpls" - ], - "ports": [ - 5678 - ], - "schemes": [ - "http" - ], - "urls": [ - "http://localhost:5678/pattern?r=200x1", - "http://localhost:5678/pattern?r=200x5,500x1", - "http://localhost:5678/pattern?r=400x1" - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json new file mode 100644 index 00000000000000..76e307f27d8411 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json @@ -0,0 +1,12 @@ +{ + "schemes": [ + "http" + ], + "ports": [ + 5678 + ], + "locations": [ + "mpls" + ], + "tags": [] +} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json deleted file mode 100644 index c0dac6c3711bc7..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_page_title.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "monitorPageTitle": { - "id": "0002-up", - "url": "http://localhost:5678/pattern?r=200x1", - "name": "" - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json deleted file mode 100644 index 88998033655954..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/monitor_status_by_id.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - { - "timestamp": "2019-09-11T03:40:34.371Z", - "monitor": { - "status": "up", - "duration": { - "us": 24627 - } - }, - "observer": { - "geo": { - "name": "mpls" - } - }, - "tls": null, - "url": { - "full": "http://localhost:5678/pattern?r=200x1" - } - } -] \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json index 93d63bad66e30a..12d8f514a3a309 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot.json @@ -1,6 +1,5 @@ { - "up": 93, + "up": 10, "down": 7, - "mixed": 0, - "total": 100 + "total": 17 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json index 94c1ffbc742908..c1e7f0ba247fb4 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_empty.json @@ -1,6 +1,5 @@ { "up": 0, - "down": 7, - "mixed": 0, - "total": 7 + "down": 0, + "total": 0 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json index 94c1ffbc742908..94777570dd6f0b 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_down.json @@ -1,6 +1,5 @@ { "up": 0, "down": 7, - "mixed": 0, "total": 7 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json index 2d79880e7c0ee0..42a15817073602 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/snapshot_filtered_by_up.json @@ -1,6 +1,5 @@ { - "up": 93, + "up": 10, "down": 0, - "mixed": 0, - "total": 93 + "total": 10 } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts b/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts index 362803d2b85509..45cc9011773a9c 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/helpers/expect_fixture_eql.ts @@ -10,6 +10,7 @@ import { join } from 'path'; import { cloneDeep } from 'lodash'; const fixturesDir = join(__dirname, '..', 'fixtures'); +const restFixturesDir = join(__dirname, '../../rest/', 'fixtures'); const excludeFieldsFrom = (from: any, excluder?: (d: any) => any): any => { const clone = cloneDeep(from); @@ -20,7 +21,11 @@ const excludeFieldsFrom = (from: any, excluder?: (d: any) => any): any => { }; export const expectFixtureEql = (data: T, fixtureName: string, excluder?: (d: T) => void) => { - const fixturePath = join(fixturesDir, `${fixtureName}.json`); + let fixturePath = join(fixturesDir, `${fixtureName}.json`); + if (!fs.existsSync(fixturePath)) { + fixturePath = join(restFixturesDir, `${fixtureName}.json`); + } + const dataExcluded = excludeFieldsFrom(data, excluder); expect(dataExcluded).not.to.be(undefined); if (process.env.UPDATE_UPTIME_FIXTURES) { diff --git a/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts b/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts index 9bfdf04c8dbe3e..f89905f0da04fe 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/helpers/make_checks.ts @@ -5,11 +5,12 @@ */ import uuid from 'uuid'; -import { merge } from 'lodash'; +import { merge, flattenDeep } from 'lodash'; + +const INDEX_NAME = 'heartbeat-8.0.0'; export const makePing = async ( es: any, - index: string, monitorId: string, fields: { [key: string]: any }, mogrify: (doc: any) => any @@ -101,7 +102,7 @@ export const makePing = async ( const doc = mogrify(merge(baseDoc, fields)); await es.index({ - index, + index: INDEX_NAME, refresh: true, body: doc, }); @@ -111,7 +112,6 @@ export const makePing = async ( export const makeCheck = async ( es: any, - index: string, monitorId: string, numIps: number, fields: { [key: string]: any }, @@ -137,7 +137,7 @@ export const makeCheck = async ( if (i === numIps - 1) { pingFields.summary = summary; } - const doc = await makePing(es, index, monitorId, pingFields, mogrify); + const doc = await makePing(es, monitorId, pingFields, mogrify); docs.push(doc); // @ts-ignore summary[doc.monitor.status]++; @@ -147,16 +147,78 @@ export const makeCheck = async ( export const makeChecks = async ( es: any, - index: string, monitorId: string, numChecks: number, numIps: number, + every: number, // number of millis between checks fields: { [key: string]: any } = {}, mogrify: (doc: any) => any = d => d ) => { const checks = []; + const oldestTime = new Date().getTime() - numChecks * every; + let newestTime = oldestTime; for (let li = 0; li < numChecks; li++) { - checks.push(await makeCheck(es, index, monitorId, numIps, fields, mogrify)); + const checkDate = new Date(newestTime + every); + newestTime = checkDate.getTime() + every; + fields = merge(fields, { + '@timestamp': checkDate.toISOString(), + monitor: { + timespan: { + gte: checkDate.toISOString(), + lt: new Date(newestTime).toISOString(), + }, + }, + }); + checks.push(await makeCheck(es, monitorId, numIps, fields, mogrify)); } + return checks; }; + +export const makeChecksWithStatus = async ( + es: any, + monitorId: string, + numChecks: number, + numIps: number, + every: number, + fields: { [key: string]: any } = {}, + status: 'up' | 'down', + mogrify: (doc: any) => any = d => d +) => { + const oppositeStatus = status === 'up' ? 'down' : 'up'; + + return await makeChecks(es, monitorId, numChecks, numIps, every, fields, d => { + d.monitor.status = status; + if (d.summary) { + d.summary[status] += d.summary[oppositeStatus]; + d.summary[oppositeStatus] = 0; + } + + return mogrify(d); + }); +}; + +// Helper for processing a list of checks to find the time picker bounds. +export const getChecksDateRange = (checks: any[]) => { + // Flatten 2d arrays + const flattened = flattenDeep(checks); + + let startTime = 1 / 0; + let endTime = -1 / 0; + flattened.forEach(c => { + const ts = Date.parse(c['@timestamp']); + + if (ts < startTime) { + startTime = ts; + } + + if (ts > endTime) { + endTime = ts; + } + }); + + return { + start: new Date(startTime).toISOString(), + end: new Date(endTime).toISOString(), + }; +}; diff --git a/x-pack/test/api_integration/apis/uptime/graphql/index.js b/x-pack/test/api_integration/apis/uptime/graphql/index.js index 3a696244a5ebaa..64999761fde4ec 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/index.js +++ b/x-pack/test/api_integration/apis/uptime/graphql/index.js @@ -11,11 +11,8 @@ export default function({ loadTestFile }) { // verifying the pre-loaded documents are returned in a way that // matches the snapshots contained in './fixtures' loadTestFile(require.resolve('./doc_count')); - loadTestFile(require.resolve('./filter_bar')); loadTestFile(require.resolve('./monitor_charts')); - loadTestFile(require.resolve('./monitor_page_title')); loadTestFile(require.resolve('./monitor_states')); - loadTestFile(require.resolve('./monitor_status_bar')); loadTestFile(require.resolve('./ping_list')); loadTestFile(require.resolve('./snapshot_histogram')); }); diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts deleted file mode 100644 index 56d22610ffe491..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_page_title.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { monitorPageTitleQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_page_title_query'; -import { FtrProviderContext } from '../../../ftr_provider_context'; -import { expectFixtureEql } from './helpers/expect_fixture_eql'; - -export default function({ getService }: FtrProviderContext) { - describe('monitor_page_title', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('will fetch a title for a given monitorId', async () => { - const getMonitorTitleQuery = { - operationName: 'MonitorPageTitle', - query: monitorPageTitleQueryString, - variables: { - monitorId: '0002-up', - }, - }; - - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getMonitorTitleQuery }); - - expectFixtureEql(data, 'monitor_page_title'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts index c305bb99c28f79..511cdb6d004fa6 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { monitorStatesQueryString } from '../../../../../legacy/plugins/uptime/public/queries/monitor_states_query'; import { expectFixtureEql } from './helpers/expect_fixture_eql'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { makeChecks } from './helpers/make_checks'; +import { makeChecksWithStatus } from './helpers/make_checks'; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -104,11 +104,10 @@ export default function({ getService }: FtrProviderContext) { }; before(async () => { - const index = 'heartbeat-8.0.0'; - const es = getService('legacyEs'); dateRangeStart = new Date().toISOString(); - checks = await makeChecks(es, index, testMonitorId, 1, numIps, {}, d => { + checks = await makeChecksWithStatus(es, testMonitorId, 1, numIps, 1, {}, 'up', d => { + // turn an all up status into having at least one down if (d.summary) { d.monitor.status = 'down'; d.summary.up--; diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js b/x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js deleted file mode 100644 index 5d03c90bb20327..00000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_status_bar.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { monitorStatusBarQueryString } from '../../../../../legacy/plugins/uptime/public/queries'; -import { expectFixtureEql } from './helpers/expect_fixture_eql'; - -export default function({ getService }) { - describe('monitorStatusBar query', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('returns the status for all monitors with no ID filtering', async () => { - const getMonitorStatusBarQuery = { - operationName: 'MonitorStatus', - query: monitorStatusBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - }, - }; - const { - body: { - data: { monitorStatus: responseData }, - }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getMonitorStatusBarQuery }); - - expectFixtureEql(responseData, 'monitor_status_all', res => - res.forEach(i => delete i.millisFromNow) - ); - }); - - it('returns the status for only the given monitor', async () => { - const getMonitorStatusBarQuery = { - operationName: 'MonitorStatus', - query: monitorStatusBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - monitorId: '0002-up', - }, - }; - const res = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getMonitorStatusBarQuery }); - - expectFixtureEql(res.body.data.monitorStatus, 'monitor_status_by_id'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/rest/filters.ts b/x-pack/test/api_integration/apis/uptime/rest/filters.ts new file mode 100644 index 00000000000000..6cec6143a6d7c6 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/filters.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const getApiPath = (dateRangeStart: string, dateRangeEnd: string, filters?: string) => + `/api/uptime/filters?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}${ + filters ? `&filters=${filters}` : '' + }`; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('filter group endpoint', () => { + const dateRangeStart = '2019-01-28T17:40:08.078Z'; + const dateRangeEnd = '2025-01-28T19:00:16.078Z'; + + it('returns expected filters', async () => { + const resp = await supertest.get(getApiPath(dateRangeStart, dateRangeEnd)); + expectFixtureEql(resp.body, 'filters'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json new file mode 100644 index 00000000000000..1702cb2c21007e --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/monitor_latest_status.json @@ -0,0 +1,89 @@ +{ + "@timestamp": "2019-09-11T03:40:34.371Z", + "agent": { + "ephemeral_id": "412a92a8-2142-4b1a-a7a2-1afd32e12f85", + "hostname": "avc-x1x", + "id": "04e1d082-65bc-4929-8d65-d0768a2621c4", + "type": "heartbeat", + "version": "8.0.0" + }, + "ecs": { + "version": "1.1.0" + }, + "event": { + "dataset": "uptime" + }, + "host": { + "name": "avc-x1x" + }, + "http": { + "response": { + "body": { + "bytes": 3, + "hash": "27badc983df1780b60c2b3fa9d3a19a00e46aac798451f0febdca52920faaddf" + }, + "status_code": 200 + }, + "rtt": { + "content": { + "us": 57 + }, + "response_header": { + "us": 262 + }, + "total": { + "us": 20331 + }, + "validate": { + "us": 319 + }, + "write_request": { + "us": 82 + } + } + }, + "monitor": { + "check_group": "d76f0762-d445-11e9-88e3-3e80641b9c71", + "duration": { + "us": 24627 + }, + "id": "0002-up", + "ip": "127.0.0.1", + "name": "", + "status": "up", + "type": "http" + }, + "observer": { + "geo": { + "location": "37.926868, -78.024902", + "name": "mpls" + }, + "hostname": "avc-x1x" + }, + "resolve": { + "ip": "127.0.0.1", + "rtt": { + "us": 4218 + } + }, + "summary": { + "down": 0, + "up": 1 + }, + "tcp": { + "rtt": { + "connect": { + "us": 103 + } + } + }, + "timestamp": "2019-09-11T03:40:34.371Z", + "url": { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=200x1", + "path": "/pattern", + "port": 5678, + "query": "r=200x1", + "scheme": "http" + } + } diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json new file mode 100644 index 00000000000000..d8367ea67052fa --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/selected_monitor.json @@ -0,0 +1,28 @@ +{ + "monitor": { + "check_group": "d76f0762-d445-11e9-88e3-3e80641b9c71", + "duration": { + "us": 24627 + }, + "id": "0002-up", + "ip": "127.0.0.1", + "name": "", + "status": "up", + "type": "http" + }, + "observer": { + "geo": { + "location": "37.926868, -78.024902", + "name": "mpls" + }, + "hostname": "avc-x1x" + }, + "url": { + "domain": "localhost", + "full": "http://localhost:5678/pattern?r=200x1", + "path": "/pattern", + "port": 5678, + "query": "r=200x1", + "scheme": "http" + } +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index b76d3f7c2e44ab..a86411f7c49ece 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -9,8 +9,16 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('uptime REST endpoints', () => { - before('load heartbeat data', () => esArchiver.load('uptime/full_heartbeat')); - after('unload', () => esArchiver.unload('uptime/full_heartbeat')); - loadTestFile(require.resolve('./snapshot')); + describe('with generated data', () => { + before('load heartbeat data', () => esArchiver.load('uptime/blank')); + after('unload', () => esArchiver.unload('uptime/blank')); + loadTestFile(require.resolve('./snapshot')); + }); + describe('with real-world data', () => { + before('load heartbeat data', () => esArchiver.load('uptime/full_heartbeat')); + after('unload', () => esArchiver.unload('uptime/full_heartbeat')); + loadTestFile(require.resolve('./monitor_latest_status')); + loadTestFile(require.resolve('./selected_monitor')); + }); }); } diff --git a/x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts b/x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts new file mode 100644 index 00000000000000..749b304c87ee3a --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/monitor_latest_status.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + describe('get monitor latest status API', () => { + const dateStart = '2018-01-28T17:40:08.078Z'; + const dateEnd = '2025-01-28T19:00:16.078Z'; + const monitorId = '0002-up'; + + const supertest = getService('supertest'); + + it('returns the status for only the given monitor', async () => { + const apiResponse = await supertest.get( + `/api/uptime/monitor/status?monitorId=${monitorId}&dateStart=${dateStart}&dateEnd=${dateEnd}` + ); + expectFixtureEql(apiResponse.body, 'monitor_latest_status'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/selected_monitor.ts b/x-pack/test/api_integration/apis/uptime/rest/selected_monitor.ts new file mode 100644 index 00000000000000..ed034f58a5f59c --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/selected_monitor.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + describe('get selected monitor by ID', () => { + const monitorId = '0002-up'; + + const supertest = getService('supertest'); + + it('returns the monitor for give ID', async () => { + const apiResponse = await supertest.get( + `/api/uptime/monitor/selected?monitorId=${monitorId}` + ); + expectFixtureEql(apiResponse.body, 'selected_monitor'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts b/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts index 0175dc649b4958..b0d97837c770f9 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/snapshot.ts @@ -6,47 +6,97 @@ import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { makeChecksWithStatus, getChecksDateRange } from '../graphql/helpers/make_checks'; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('snapshot count', () => { - let dateRangeStart = '2019-01-28T17:40:08.078Z'; - let dateRangeEnd = '2025-01-28T19:00:16.078Z'; - - it('will fetch the full set of snapshot counts', async () => { - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}` - ); - expectFixtureEql(apiResponse.body, 'snapshot'); - }); + const dateRangeStart = new Date().toISOString(); + const dateRangeEnd = new Date().toISOString(); - it('will fetch a monitor snapshot filtered by down status', async () => { - const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"down","operator":"and"}}}]}}`; - const statusFilter = 'down'; - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}&statusFilter=${statusFilter}` - ); - expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_down'); - }); + describe('when no data is present', async () => { + it('returns a null snapshot', async () => { + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}` + ); - it('will fetch a monitor snapshot filtered by up status', async () => { - const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"up","operator":"and"}}}]}}`; - const statusFilter = 'up'; - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}&statusFilter=${statusFilter}` - ); - expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_up'); + expectFixtureEql(apiResponse.body, 'snapshot_empty'); + }); }); - it('returns a null snapshot when no data is present', async () => { - dateRangeStart = '2019-01-25T04:30:54.740Z'; - dateRangeEnd = '2025-01-28T04:50:54.740Z'; - const filters = `{"bool":{"must":[{"match":{"monitor.status":{"query":"down","operator":"and"}}}]}}`; - const apiResponse = await supertest.get( - `/api/uptime/snapshot/count?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}&filters=${filters}` - ); - expectFixtureEql(apiResponse.body, 'snapshot_empty'); + describe('when data is present', async () => { + const numUpMonitors = 10; + const numDownMonitors = 7; + const numIps = 2; + const checksPerMonitor = 5; + const scheduleEvery = 10000; // fake monitor checks every 10s + let dateRange: { start: string; end: string }; + + [true, false].forEach(async (includeTimespan: boolean) => { + describe(`with timespans ${includeTimespan ? 'included' : 'missing'}`, async () => { + before(async () => { + const promises: Array> = []; + + // When includeTimespan is false we have to remove the values there. + let mogrify = (d: any) => d; + if ((includeTimespan = false)) { + mogrify = (d: any): any => { + d.monitor.delete('timespan'); + return d; + }; + } + + const makeMonitorChecks = async (monitorId: string, status: 'up' | 'down') => { + return makeChecksWithStatus( + getService('legacyEs'), + monitorId, + checksPerMonitor, + numIps, + scheduleEvery, + {}, + status, + mogrify + ); + }; + + for (let i = 0; i < numUpMonitors; i++) { + promises.push(makeMonitorChecks(`up-${i}`, 'up')); + } + for (let i = 0; i < numDownMonitors; i++) { + promises.push(makeMonitorChecks(`down-${i}`, 'down')); + } + + const allResults = await Promise.all(promises); + dateRange = getChecksDateRange(allResults); + }); + + it('will count all statuses correctly', async () => { + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}` + ); + + expectFixtureEql(apiResponse.body, 'snapshot'); + }); + + it('will fetch a monitor snapshot filtered by down status', async () => { + const statusFilter = 'down'; + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}&statusFilter=${statusFilter}` + ); + + expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_down'); + }); + + it('will fetch a monitor snapshot filtered by up status', async () => { + const statusFilter = 'up'; + const apiResponse = await supertest.get( + `/api/uptime/snapshot/count?dateRangeStart=${dateRange.start}&dateRangeEnd=${dateRange.end}&statusFilter=${statusFilter}` + ); + expectFixtureEql(apiResponse.body, 'snapshot_filtered_by_up'); + }); + }); + }); }); }); } diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts index f148d62421ff8b..ad4f81777e7804 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts @@ -67,9 +67,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -125,9 +123,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -178,9 +174,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts index 9ac6d4fdef19f4..ee58be76928b3a 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts index 9190e0b4886ce3..e2d5efac4644cc 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts @@ -60,10 +60,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows apm navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'APM', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['APM', 'Management']); }); it('can navigate to APM app', async () => { @@ -111,9 +108,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows apm navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['APM', 'Management']); }); @@ -166,9 +161,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show APM navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts index 191ba5c4d1e25b..1ac1784e0e05db 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('APM'); }); @@ -61,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts index a58eb61ec4ca2b..d0e37ec8e3f359 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts @@ -65,9 +65,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows canvas navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Canvas', 'Management']); }); @@ -143,9 +141,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows canvas navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Canvas', 'Management']); }); diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts index 5a6857901536ff..28b572401892b5 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Canvas'); }); @@ -98,9 +96,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Canvas'); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index aa6860b35763f8..d25fae3c4894cd 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -75,10 +75,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Dashboard', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Dashboard', 'Management']); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -255,9 +252,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows dashboard navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Dashboard', 'Management']); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts index c1197fa7023c51..ebe08a60c25636 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts @@ -43,9 +43,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dashboard'); }); @@ -107,9 +105,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dashboard'); }); diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts index fd7739e6930d01..494fd71ea6f34a 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts @@ -63,10 +63,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows Dev Tools navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Dev Tools', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Dev Tools', 'Management']); }); describe('console', () => { @@ -146,9 +143,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`shows 'Dev Tools' navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Dev Tools', 'Management']); }); diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts index e3bc3a1c6ce11c..4184d223a96864 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dev Tools'); }); @@ -79,9 +77,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dev Tools'); }); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 553ce459ebb182..1912b16d96f36c 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -81,10 +81,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Discover', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Discover', 'Management']); }); it('shows save button', async () => { @@ -170,9 +167,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows discover navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts index 3e5dcd7b0c9877..e6b6f28f8b92fc 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts @@ -49,9 +49,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Discover'); }); @@ -93,9 +91,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Discover'); }); diff --git a/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts b/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts index 1d1fb566eb075f..d8eb969b99b3b1 100644 --- a/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts +++ b/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('EEndpoint'); }); @@ -70,9 +68,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('EEndpoint'); }); }); diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts index acc8943033a1a8..a2b062e6ef84fb 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts @@ -64,10 +64,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows graph navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Graph', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Graph', 'Management']); }); it('landing page shows "Create new graph" button', async () => { @@ -129,9 +126,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows graph navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Graph', 'Management']); }); @@ -183,9 +178,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show graph navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Graph'); }); diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts index 0945b35ba09309..a0b0d5bef96680 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts @@ -34,9 +34,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Graph'); }); @@ -75,9 +73,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Graph'); }); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts index 4929bb52c170c4..30cdc95b38e62e 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts @@ -69,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -125,9 +123,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -179,9 +175,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts index bc8542288410c0..6a2b77de17f457 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts index 4d61e0996419cb..5062f094061c03 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts @@ -60,9 +60,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows metrics navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Metrics', 'Management']); }); @@ -175,9 +173,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows metrics navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Metrics', 'Management']); }); @@ -417,9 +413,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show metrics navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain(['Metrics']); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts index 300b22e5bcbc32..7c2a11a542d66e 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts @@ -48,9 +48,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Metrics'); }); @@ -101,9 +99,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Metrics'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts index d092e6736656ec..b9634c29dac1c9 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts @@ -57,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows logs navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Logs', 'Management']); }); @@ -122,9 +120,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows logs navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Logs', 'Management']); }); @@ -187,9 +183,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show logs navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain('Logs'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts index 8230b25efbbf9b..6b078d2cfa71af 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts @@ -36,9 +36,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Logs'); }); @@ -77,9 +75,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain('Logs'); }); diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts index 8b2df502dc100d..8fb6f21c778d3f 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts @@ -80,9 +80,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show ml navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Machine Learning'); }); }); @@ -103,9 +101,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows ML navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Machine Learning'); }); }); diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts index 13036737218bc3..fc94688e98811b 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts @@ -39,9 +39,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Machine Learning'); }); @@ -71,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Machine Learning'); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts index cf31f445a96f3f..804ad5725edfd3 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts @@ -65,9 +65,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Maps', 'Management']); }); @@ -154,9 +152,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Maps', 'Management']); }); @@ -251,9 +247,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('does not show Maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts index 0c86b47b373e6a..e157586aecead7 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts @@ -42,9 +42,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Maps'); }); diff --git a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts index 8848df83d36d6e..d985da42ab5eda 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts @@ -76,9 +76,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show monitoring navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); }); @@ -99,9 +97,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows monitoring navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); }); diff --git a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts index 80f33ff6175c56..7459b53ca4a32f 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts @@ -41,9 +41,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); @@ -74,9 +72,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); diff --git a/x-pack/test/functional/apps/security/basic_license/index.ts b/x-pack/test/functional/apps/security/basic_license/index.ts new file mode 100644 index 00000000000000..0dbbd3988f8ddb --- /dev/null +++ b/x-pack/test/functional/apps/security/basic_license/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security app - basic license', function() { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./role_mappings')); + }); +} diff --git a/x-pack/test/functional/apps/security/basic_license/role_mappings.ts b/x-pack/test/functional/apps/security/basic_license/role_mappings.ts new file mode 100644 index 00000000000000..45b325d57bee00 --- /dev/null +++ b/x-pack/test/functional/apps/security/basic_license/role_mappings.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'roleMappings']); + const testSubjects = getService('testSubjects'); + + describe('Role Mappings', function() { + before(async () => { + await pageObjects.common.navigateToApp('settings'); + }); + + it('does not render the Role Mappings UI under the basic license', async () => { + await testSubjects.missingOrFail('roleMappings'); + }); + }); +}; diff --git a/x-pack/test/functional/apps/security/index.js b/x-pack/test/functional/apps/security/index.js index b5d9b5f14be97e..827a702b92d85f 100644 --- a/x-pack/test/functional/apps/security/index.js +++ b/x-pack/test/functional/apps/security/index.js @@ -16,5 +16,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./field_level_security')); loadTestFile(require.resolve('./rbac_phase1')); loadTestFile(require.resolve('./user_email')); + loadTestFile(require.resolve('./role_mappings')); }); } diff --git a/x-pack/test/functional/apps/security/role_mappings.ts b/x-pack/test/functional/apps/security/role_mappings.ts new file mode 100644 index 00000000000000..5fed56ee79e3dd --- /dev/null +++ b/x-pack/test/functional/apps/security/role_mappings.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { parse } from 'url'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'roleMappings']); + const security = getService('security'); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const aceEditor = getService('aceEditor'); + + describe('Role Mappings', function() { + before(async () => { + await pageObjects.common.navigateToApp('roleMappings'); + }); + + it('displays a message when no role mappings exist', async () => { + await testSubjects.existOrFail('roleMappingsEmptyPrompt'); + await testSubjects.existOrFail('createRoleMappingButton'); + }); + + it('allows a role mapping to be created', async () => { + await testSubjects.click('createRoleMappingButton'); + await testSubjects.setValue('roleMappingFormNameInput', 'new_role_mapping'); + await testSubjects.setValue('roleMappingFormRoleComboBox', 'superuser'); + await browser.pressKeys(browser.keys.ENTER); + + await testSubjects.click('roleMappingsAddRuleButton'); + + await testSubjects.click('roleMappingsJSONRuleEditorButton'); + + await aceEditor.setValue( + 'roleMappingsJSONEditor', + JSON.stringify({ + all: [ + { + field: { + username: '*', + }, + }, + { + field: { + 'metadata.foo.bar': 'baz', + }, + }, + { + except: { + any: [ + { + field: { + dn: 'foo', + }, + }, + { + field: { + dn: 'bar', + }, + }, + ], + }, + }, + ], + }) + ); + + await testSubjects.click('roleMappingsVisualRuleEditorButton'); + + await testSubjects.click('saveRoleMappingButton'); + + await testSubjects.existOrFail('savedRoleMappingSuccessToast'); + }); + + it('allows a role mapping to be deleted', async () => { + await testSubjects.click(`deleteRoleMappingButton-new_role_mapping`); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.existOrFail('deletedRoleMappingSuccessToast'); + }); + + it('displays an error and returns to the listing page when navigating to a role mapping which does not exist', async () => { + await pageObjects.common.navigateToActualUrl( + 'kibana', + '#/management/security/role_mappings/edit/i-do-not-exist', + { ensureCurrentUrl: false } + ); + + await testSubjects.existOrFail('errorLoadingRoleMappingEditorToast'); + + const url = parse(await browser.getCurrentUrl()); + + expect(url.hash).to.eql('#/management/security/role_mappings?_g=()'); + }); + + describe('with role mappings', () => { + const mappings = [ + { + name: 'a_enabled_role_mapping', + enabled: true, + roles: ['superuser'], + rules: { + field: { + username: '*', + }, + }, + metadata: {}, + }, + { + name: 'b_disabled_role_mapping', + enabled: false, + role_templates: [{ template: { source: 'superuser' } }], + rules: { + field: { + username: '*', + }, + }, + metadata: {}, + }, + ]; + + before(async () => { + await Promise.all( + mappings.map(mapping => { + const { name, ...payload } = mapping; + return security.roleMappings.create(name, payload); + }) + ); + + await pageObjects.common.navigateToApp('roleMappings'); + }); + + after(async () => { + await Promise.all(mappings.map(mapping => security.roleMappings.delete(mapping.name))); + }); + + it('displays a table of all role mappings', async () => { + const rows = await testSubjects.findAll('roleMappingRow'); + expect(rows.length).to.eql(mappings.length); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const mapping = mappings[i]; + + const name = await ( + await testSubjects.findDescendant('roleMappingName', row) + ).getVisibleText(); + + const enabled = + (await ( + await testSubjects.findDescendant('roleMappingEnabled', row) + ).getVisibleText()) === 'Enabled'; + + expect(name).to.eql(mapping.name); + expect(enabled).to.eql(mapping.enabled); + } + }); + + it('allows a role mapping to be edited', async () => { + await testSubjects.click('roleMappingName'); + await testSubjects.click('saveRoleMappingButton'); + await testSubjects.existOrFail('savedRoleMappingSuccessToast'); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/snapshot_restore/home_page.ts b/x-pack/test/functional/apps/snapshot_restore/home_page.ts index 99d3ea7834e6ba..608c7f321a08f0 100644 --- a/x-pack/test/functional/apps/snapshot_restore/home_page.ts +++ b/x-pack/test/functional/apps/snapshot_restore/home_page.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'snapshotRestore']); const log = getService('log'); + const es = getService('legacyEs'); describe('Home page', function() { this.tags('smoke'); @@ -26,5 +27,37 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const repositoriesButton = await pageObjects.snapshotRestore.registerRepositoryButton(); expect(await repositoriesButton.isDisplayed()).to.be(true); }); + + describe('Repositories Tab', async () => { + before(async () => { + await es.snapshot.createRepository({ + repository: 'my-repository', + body: { + type: 'fs', + settings: { + location: '/tmp/es-backups/', + compress: true, + }, + }, + verify: true, + }); + await pageObjects.snapshotRestore.navToRepositories(); + }); + + it('cleanup repository', async () => { + await pageObjects.snapshotRestore.viewRepositoryDetails('my-repository'); + await pageObjects.common.sleep(25000); + const cleanupResponse = await pageObjects.snapshotRestore.performRepositoryCleanup(); + await pageObjects.common.sleep(25000); + expect(cleanupResponse).to.contain('results'); + expect(cleanupResponse).to.contain('deleted_bytes'); + expect(cleanupResponse).to.contain('deleted_blobs'); + }); + after(async () => { + await es.snapshot.deleteRepository({ + repository: 'my-repository', + }); + }); + }); }); }; diff --git a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts index 46f0be1e6f6d65..1e79c76bf83e5e 100644 --- a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts +++ b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts @@ -55,9 +55,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); @@ -131,9 +129,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts index 64fb218a62c80f..dea45f161e4510 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts @@ -59,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows timelion navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Timelion', 'Management']); }); @@ -113,9 +111,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows timelion navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Timelion', 'Management']); }); diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts index ea5e255071dadd..fb203a23359bdd 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts @@ -38,9 +38,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Timelion'); }); @@ -71,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Timelion'); }); diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts index c5a597cdaffb0c..a004f8db66823c 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts @@ -64,10 +64,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows uptime navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Uptime', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Uptime', 'Management']); }); it('can navigate to Uptime app', async () => { @@ -117,9 +114,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows uptime navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Uptime', 'Management']); }); @@ -170,9 +165,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show uptime navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts index 96bc3c5f74f598..77c5b323340bf8 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Uptime'); }); @@ -59,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 9a337c86185fe0..bcfb72967b75a1 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -29,6 +29,27 @@ export default ({ getPageObjects }: FtrProviderContext) => { await pageObjects.uptime.pageHasExpectedIds(['0000-intermittent']); }); + it('applies filters for multiple fields', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.selectFilterItems({ + location: ['mpls'], + port: ['5678'], + scheme: ['http'], + }); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + ]); + }); + it('pagination is cleared when filter criteria changes', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await pageObjects.uptime.changePage('next'); @@ -64,5 +85,27 @@ export default ({ getPageObjects }: FtrProviderContext) => { '0009-up', ]); }); + + describe('snapshot counts', () => { + it('updates the snapshot count when status filter is set to down', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange( + DEFAULT_DATE_START, + DEFAULT_DATE_END + ); + await pageObjects.uptime.setStatusFilter('down'); + const counts = await pageObjects.uptime.getSnapshotCount(); + expect(counts).to.eql({ up: '0', down: '7' }); + }); + + it('updates the snapshot count when status filter is set to up', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange( + DEFAULT_DATE_START, + DEFAULT_DATE_END + ); + await pageObjects.uptime.setStatusFilter('up'); + const counts = await pageObjects.uptime.getSnapshotCount(); + expect(counts).to.eql({ up: '93', down: '0' }); + }); + }); }); }; diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index 86fe606ecafad5..d55076cb0ab43b 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -74,9 +74,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows visualize navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Visualize', 'Management']); }); @@ -190,9 +188,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows visualize navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Visualize', 'Management']); }); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts index d0fdc7c95ea380..9193862d2ba9e4 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Visualize'); }); @@ -81,9 +79,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Visualize'); }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 17235c61c7d8ce..664bfdf8d2a746 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -69,7 +69,7 @@ export default async function({ readConfigFile }) { esTestCluster: { license: 'trial', from: 'snapshot', - serverArgs: [], + serverArgs: ['path.repo=/tmp/'], }, kbnTestServer: { @@ -160,6 +160,10 @@ export default async function({ readConfigFile }) { ml: { pathname: '/app/ml', }, + roleMappings: { + pathname: '/app/kibana', + hash: '/management/security/role_mappings', + }, rollupJob: { pathname: '/app/kibana', hash: '/management/elasticsearch/rollup_jobs/', diff --git a/x-pack/test/functional/config_security_basic.js b/x-pack/test/functional/config_security_basic.js new file mode 100644 index 00000000000000..12d94e922a97c7 --- /dev/null +++ b/x-pack/test/functional/config_security_basic.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable import/no-default-export */ + +import { resolve } from 'path'; + +import { services } from './services'; +import { pageObjects } from './page_objects'; + +// the default export of config files must be a config provider +// that returns an object with the projects config values +export default async function({ readConfigFile }) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + return { + // list paths to the files that contain your plugins tests + testFiles: [resolve(__dirname, './apps/security/basic_license')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'basic', + from: 'snapshot', + serverArgs: [], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--telemetry.banner=false', + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + }, + }, + // the apps section defines the urls that + // `PageObjects.common.navigateTo(appKey)` will use. + // Merge urls for your plugin with the urls defined in + // Kibana's config in order to use this helper + apps: { + ...kibanaFunctionalConfig.get('apps'), + }, + + // choose where esArchiver should load archives from + esArchiver: { + directory: resolve(__dirname, 'es_archives'), + }, + + // choose where screenshots should be saved + screenshots: { + directory: resolve(__dirname, 'screenshots'), + }, + + junit: { + reportName: 'Chrome X-Pack UI Functional Tests', + }, + }; +} diff --git a/x-pack/test/functional/es_archives/uptime/blank/mappings.json b/x-pack/test/functional/es_archives/uptime/blank/mappings.json index eb383cc87563d0..a1b0696cdaadc6 100644 --- a/x-pack/test/functional/es_archives/uptime/blank/mappings.json +++ b/x-pack/test/functional/es_archives/uptime/blank/mappings.json @@ -1267,6 +1267,9 @@ "type": { "ignore_above": 1024, "type": "keyword" + }, + "timespan": { + "type": "date_range" } } }, diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 82011c48d44603..18ea515a73147c 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -46,6 +46,7 @@ import { RemoteClustersPageProvider } from './remote_clusters_page'; import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page'; import { LensPageProvider } from './lens_page'; import { InfraMetricExplorerProvider } from './infra_metric_explorer'; +import { RoleMappingsPageProvider } from './role_mappings_page'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -78,4 +79,5 @@ export const pageObjects = { remoteClusters: RemoteClustersPageProvider, copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider, lens: LensPageProvider, + roleMappings: RoleMappingsPageProvider, }; diff --git a/x-pack/test/functional/page_objects/role_mappings_page.ts b/x-pack/test/functional/page_objects/role_mappings_page.ts new file mode 100644 index 00000000000000..b1adfb00af739a --- /dev/null +++ b/x-pack/test/functional/page_objects/role_mappings_page.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function RoleMappingsPageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + async appTitleText() { + return await testSubjects.getVisibleText('appTitle'); + }, + }; +} diff --git a/x-pack/test/functional/page_objects/snapshot_restore_page.ts b/x-pack/test/functional/page_objects/snapshot_restore_page.ts index 25bdfc70757272..1c8ba9f633111c 100644 --- a/x-pack/test/functional/page_objects/snapshot_restore_page.ts +++ b/x-pack/test/functional/page_objects/snapshot_restore_page.ts @@ -3,11 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { FtrProviderContext } from '../ftr_provider_context'; export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { async appTitleText() { @@ -16,5 +16,50 @@ export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) async registerRepositoryButton() { return await testSubjects.find('registerRepositoryButton'); }, + async navToRepositories() { + await testSubjects.click('repositories_tab'); + await retry.waitForWithTimeout( + 'Wait for register repository button to be on page', + 10000, + async () => { + return await testSubjects.isDisplayed('registerRepositoryButton'); + } + ); + }, + async getRepoList() { + const table = await testSubjects.find('repositoryTable'); + const rows = await table.findAllByCssSelector('[data-test-subj="row"]'); + return await Promise.all( + rows.map(async row => { + return { + repoName: await ( + await row.findByCssSelector('[data-test-subj="Name_cell"]') + ).getVisibleText(), + repoLink: await ( + await row.findByCssSelector('[data-test-subj="Name_cell"]') + ).findByCssSelector('a'), + repoType: await ( + await row.findByCssSelector('[data-test-subj="Type_cell"]') + ).getVisibleText(), + repoEdit: await row.findByCssSelector('[data-test-subj="editRepositoryButton"]'), + repoDelete: await row.findByCssSelector('[data-test-subj="deleteRepositoryButton"]'), + }; + }) + ); + }, + async viewRepositoryDetails(name: string) { + const repos = await this.getRepoList(); + if (repos.length === 1) { + const repoToView = repos.filter(r => (r.repoName = name))[0]; + await repoToView.repoLink.click(); + } + await retry.waitForWithTimeout(`Repo title should be ${name}`, 10000, async () => { + return (await testSubjects.getVisibleText('title')) === name; + }); + }, + async performRepositoryCleanup() { + await testSubjects.click('cleanupRepositoryButton'); + return await testSubjects.getVisibleText('cleanupCodeBlock'); + }, }; } diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index 26c95c3bf526d8..f04f96148583fa 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -70,5 +70,20 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo await uptimeService.setStatusFilterDown(); } } + + public async selectFilterItems(filters: Record) { + for (const key in filters) { + if (filters.hasOwnProperty(key)) { + const values = filters[key]; + for (let i = 0; i < values.length; i++) { + await uptimeService.selectFilterItem(key, values[i]); + } + } + } + } + + public async getSnapshotCount() { + return await uptimeService.getSnapshotCount(); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 40d2e3dafc7f84..ca38c2e9dd897b 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -49,5 +49,20 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async setStatusFilterDown() { await testSubjects.click('xpack.uptime.filterBar.filterStatusDown'); }, + async selectFilterItem(filterType: string, option: string) { + const popoverId = `filter-popover_${filterType}`; + const optionId = `filter-popover-item_${option}`; + await testSubjects.existOrFail(popoverId); + await testSubjects.click(popoverId); + await testSubjects.existOrFail(optionId); + await testSubjects.click(optionId); + await testSubjects.click(popoverId); + }, + async getSnapshotCount() { + return { + up: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.up'), + down: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.down'), + }; + }, }; } diff --git a/x-pack/test/pki_api_integration/config.ts b/x-pack/test/pki_api_integration/config.ts index 50b41ad2518278..a445b3d4943b0f 100644 --- a/x-pack/test/pki_api_integration/config.ts +++ b/x-pack/test/pki_api_integration/config.ts @@ -7,7 +7,7 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; // @ts-ignore -import { CA_CERT_PATH, ES_KEY_PATH, ES_CERT_PATH } from '@kbn/dev-utils'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { services } from './services'; export default async function({ readConfigFile }: FtrConfigProviderContext) { @@ -54,8 +54,8 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), '--server.ssl.enabled=true', - `--server.ssl.key=${ES_KEY_PATH}`, - `--server.ssl.certificate=${ES_CERT_PATH}`, + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, `--server.ssl.certificateAuthorities=${JSON.stringify([ CA_CERT_PATH, resolve(__dirname, './fixtures/kibana_ca.crt'), diff --git a/x-pack/test/pki_api_integration/fixtures/README.md b/x-pack/test/pki_api_integration/fixtures/README.md index 0fcbc76183b48d..ac2be482c6e333 100644 --- a/x-pack/test/pki_api_integration/fixtures/README.md +++ b/x-pack/test/pki_api_integration/fixtures/README.md @@ -1,7 +1,16 @@ # PKI Fixtures -* `es_ca.key` - the CA key used to sign certificates from @kbn/dev-utils that are used and trusted by test Elasticsearch server. -* `first_client.p12` and `second_client.p12` - the client certificate bundles signed by `es_ca.key` and hence trusted by -both test Kibana and Elasticsearch servers. +* `first_client.p12` and `second_client.p12` - the client certificate bundles signed by the Elastic Stack CA (in `kbn-dev-utils`) +and hence trusted by both test Kibana and Elasticsearch servers. * `untrusted_client.p12` - the client certificate bundle trusted by test Kibana server, but not test Elasticsearch test server. -* `kibana_ca.crt` and `kibana_ca.key` - the CA certificate and key trusted by test Kibana server only. \ No newline at end of file +* `kibana_ca.crt` and `kibana_ca.key` - the CA certificate and key trusted by test Kibana server only. + +The `first_client.p12` and `second_client.p12` files were generated the same time as the other certificates in `kbn-dev-utils`, using the +following commands: + +``` +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name first_client --pass "" +bin/elasticsearch-certutil cert -days 18250 --ca elastic-stack-ca.p12 --ca-pass castorepass --name second_client --pass "" +``` + +If that CA is ever changed, these two files must be regenerated. diff --git a/x-pack/test/pki_api_integration/fixtures/es_ca.key b/x-pack/test/pki_api_integration/fixtures/es_ca.key deleted file mode 100644 index 5428f86851e5ac..00000000000000 --- a/x-pack/test/pki_api_integration/fixtures/es_ca.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAjSJiqfwPZfvgHO1OZbxzgPn2EW/KewIHXygTAdL926Pm6R45 -G5H972B46NcSUoOZbOhDyvg6OKMJAICiXa85yOf3nyTo4APspR+K4AH60SEJohRF -mZwL/OryfiKvN5n5DxC2+Hb1wouwBUJM6DP62C24ve8YWuWwNkhJqWKe1YQUzPc1 -svqvU5uaHTzvLtp++RqSDNkcIqWl5S9Ip5PtOv6MHkCaIr2g4KQzplFwhT5qVd1Q -nYVBsQ0D8htLqUJBfjW0KHouEZpbjxJlc+EuyExS1o1+y3mVT+t2yZHAoIquh5ve -5A7a/RGJTyoR5u1DFs4Tcx2378kjA86gCQtClwIDAQABAoIBAFTOGKMzxrztQJmh -Lr6LIoyZpnaLygtoCK3xEprCAbB9KD9j3cTnUMMKIR0oPuY+FW8Pkczgo3ts2/fl -U6sfo4VJfc2vDA+vy/7cmUJJbkFDrNorfDb1QW7UbqnEhazPZIzc6lUahkpETZyb -XkMZGN3Ve3EFvojAA8ZaYYjarb52HRddLPZJ7c8ZiHfJ1jHNIvx6dIQ6CJVuovBJ -OGbbSAK8MjUtOI2XzWNHgUqGHcjVDFysuAac3ckK14TaN4KVNRl+usAMkZwqSM5u -j/ATFL9hx7nkzh3KWPsuOLMoLX7JN81z0YtT52wTxJoSiZKk/u91JHZ3NcrsOSPS -oLvVkyECgYEA16qtXvtmboAbqeuXf0nF+7QD0b+MdaRFIacqTG0LpEgY9Tjgs9Pn -6z44tHABWPVkRLNQZiky99MAq4Ci354Bk9dmylCw9ADH78VGmKWklbQEr1rw4dqm -DHTj9NQ79SyTdiasQjnnxCilWkrO6ZUqD8og4DT5MhzfxO/ZND8arGsCgYEAp4df -oI5lwlc1n9X/G9RQAKwNM5un8RmReleUVemjkcvWwvZVEjV0Gcc1WtjB+77Y5B9B -CM3laURDGrAgX5VS/I2jb0xqBNUr8XccSkDQAP9UuVPZgxpS+8d0R3fxVzniHWwR -WC2dW/Is40i/6+7AkFXhkiFiqxkvSg4pWHPazYUCgYB/gP7C+urSRZcVXJ3SuXD9 -oK3pYc/O9XGRtd0CFi4d0CpBQIFIj+27XKv1sYp6Z4oCO+k6nPzvG6Z3vrOMdUQF -fgHddttHRvbtwLo+ISAvCaEDc0aaoMQu9SSYaKmSB+qenbqV5NorVMR9n2C5JGEb -uKq7I1Z41C1Pp2XIx84jRQKBgQCjKvfZsjesZDJnfg9dtJlDPlARXt7gte16gkiI -sOnOfAGtnCzZclSlMuBlnk65enVXIpW+FIQH1iOhn7+4OQE92FpBceSk1ldZdJCK -RbwR7J5Bb0igJ4iBkA9R+KGIOmlgDLyL7MmiHyrXKCk9iynkqrDsGjY2vW3QrCBa -9WQ73QKBgQDAYZzplO4TPoPK9AnxoW/HpSwGEO7Fb8fLyPg94CvHn4QBCFJUKuTn -hBp/TJgF6CjQWQMr2FKVFF33Ow7+Qa96YGvmYlEjR/71D4Rlprj5JJpuO154DI3I -YIMNTjvwEQEI+YamMarKsz0Kq+I1EYSAf6bQ4H2PgxDxwTXaLkl0RA== ------END RSA PRIVATE KEY----- diff --git a/x-pack/test/pki_api_integration/fixtures/first_client.p12 b/x-pack/test/pki_api_integration/fixtures/first_client.p12 index 62da80d9ab80ef..9d838199e83926 100644 Binary files a/x-pack/test/pki_api_integration/fixtures/first_client.p12 and b/x-pack/test/pki_api_integration/fixtures/first_client.p12 differ diff --git a/x-pack/test/pki_api_integration/fixtures/second_client.p12 b/x-pack/test/pki_api_integration/fixtures/second_client.p12 index 1c85686cb7b686..f41c0e030ba793 100644 Binary files a/x-pack/test/pki_api_integration/fixtures/second_client.p12 and b/x-pack/test/pki_api_integration/fixtures/second_client.p12 differ diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml index 132faffd416672..a890fe812987b8 100644 --- a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml @@ -6,25 +6,25 @@ - - MIIDMDCCAhgCCQCkOD7fnHiQrTANBgkqhkiG9w0BAQUFADBZMQswCQYDVQQGEwJB - VTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0 - cyBQdHkgTHRkMRIwEAYDVQQDEwlsb2NhbGhvc3QwIBcNMTYwMTE5MjExNjQ5WhgP - MjA4NDAyMDYyMTE2NDlaMFkxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0 - YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNVBAMT - CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALR63SnR - LW/Dgl0Vuy8gB6KjWwvajRWeXNgvlf6LX56oNsUFREHBLQlC2uT5R26F3tOqCDbs - MFNoyDjMBinXRRFJJ2Sokue7GSsGvBv41LMTHnO/MeCCbEOqghJS/QI89cV+u+Aw - 9U+v426KAlCa1sGuE2+3/JvqdBQyheiukmGLiJ0OofpfgpYuFmKi2uYBKU3qzjUx - D01wQ4rCpq5nEnksGhgBeBDnheYmmDsj/wDvnz1exK/WprvTiHQ5MwuIQ4OybwgV - WDF+zv8PXrObrsZvD/ulrjh1cakvnCe2kDYEKMRiHUDesHS2jNJkBUe+FJo4/E3U - pFoYOtdoBe69BIUCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAQhjF91G0R662XJJ7 - jGMudA9VbRVCow8s68I/GWPZmpKxPAxwz0xiv1eFIoiP416LX9amnx3yAmUoN4Wr - Cq0jsgyT1AOiSCdxkvYsqQG3SFVVt5BDLjThH66Vxi7Bach6SyATa1NG588mg7n9 - pPJ4A1rcj+5kZuwnd52kfVLP+535lylwMyoyJa2AskieRPLNSzus2eUDTR6F+9Mb - eLOwp5rMl2nNYfLXUCSqEeC6uPu0yq6Tu0N0SjThfKndd2NU1fk3zyOjxyCIhGPe - G8VhrPY4lkJ9EE9Tuq095jwd1+q9fYzlKZWhOmg+IcOwUMgbgeWpeZTAhUIZAnia - 4UH6NA== + + MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts index f8862e6fb209dc..b7b94b8eeb17ad 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts @@ -12,6 +12,7 @@ import zlib from 'zlib'; import { promisify } from 'util'; import { parseString } from 'xml2js'; import { SignedXml } from 'xml-crypto'; +import { KBN_KEY_PATH } from '@kbn/dev-utils'; /** * @file Defines a set of tools that allow us to parse and generate various SAML XML messages. @@ -24,7 +25,7 @@ const inflateRawAsync = promisify(zlib.inflateRaw); const deflateRawAsync = promisify(zlib.deflateRaw); const parseStringAsync = promisify(parseString); -const signingKey = fs.readFileSync(require.resolve('../../../../test/dev_certs/server.key')); +const signingKey = fs.readFileSync(KBN_KEY_PATH); const signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'; export async function getSAMLRequestId(urlWithSAMLRequestId: string) { diff --git a/x-pack/test_utils/stub_web_worker.ts b/x-pack/test_utils/stub_web_worker.ts new file mode 100644 index 00000000000000..2e7d5cf2098c8c --- /dev/null +++ b/x-pack/test_utils/stub_web_worker.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; + * you may not use this file except in compliance with the Elastic License. + */ + +if (!window.Worker) { + // @ts-ignore we aren't honoring the real Worker spec here + window.Worker = function Worker() { + this.postMessage = jest.fn(); + + // @ts-ignore TypeScript doesn't think this exists on the Worker interface + // https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate + this.terminate = jest.fn(); + }; +} diff --git a/yarn.lock b/yarn.lock index 96ec5213badcb2..96bb533120aa74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1361,10 +1361,10 @@ dependencies: "@elastic/apm-rum-core" "^4.7.0" -"@elastic/charts@^16.0.2": - version "16.0.2" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-16.0.2.tgz#35068a08a19534da62e9bcad700cc7b2a15bc55a" - integrity sha512-0tVyltAmAPOAfiRU1iKYk3b9++4oTn6IXvyM4SSj7Ukh5Y90XXmOtGEUPnZTiRPmup9MJi4srrm9ra9k/Kq4UQ== +"@elastic/charts@^16.1.0": + version "16.1.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-16.1.0.tgz#67cf11625dcd7e1c2cf16ef53349e6a68a73f5b1" + integrity sha512-0jZ7thhGmYC0ZdEVkxfg6M66epCD7k7BfYIi12FnrmIK+mUD2IPhR8b2TJXvaojPryN4YTNreGRncQ9R58fOoQ== dependencies: "@types/d3-shape" "^1.3.1" classnames "^2.2.6" @@ -3882,6 +3882,13 @@ dependencies: "@types/node" "*" +"@types/node-forge@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-0.9.0.tgz#e9f678ec09283f9f35cb8de6c01f86be9278ac08" + integrity sha512-J00+BIHJOfagO1Qs67Jp5CZO3VkFxY8YKMt44oBhXr+3ZYNnl8wv/vtcJyPjuH0QZ+q7+5nnc6o/YH91ZJy2pQ== + dependencies: + "@types/node" "*" + "@types/node-jose@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@types/node-jose/-/node-jose-1.1.0.tgz#26e1d234b41a39035482443ef35414bf34ba5d8b" @@ -3944,6 +3951,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-1.0.0.tgz#69f059e40a0fa93dc2e095d4142395ae6adc5d7a" integrity sha512-fCA3btjE7QFeRLfcD0Sjg+6/CnmC66HpMBoRfRzd2raTaWMJV21CCZ0LO8MOqf8onl5n0EPfjq4zDhbyX8SVwA== +"@types/pegjs@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@types/pegjs/-/pegjs-0.10.1.tgz#9a2f3961dc62430fdb21061eb0ddbd890f9e3b94" + integrity sha512-ra8IchO9odGQmYKbm+94K58UyKCEKdZh9y0vxhG4pIpOJOBlC1C+ZtBVr6jLs+/oJ4pl+1p/4t3JtBA8J10Vvw== + "@types/pngjs@^3.3.2": version "3.3.2" resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.2.tgz#8ed3bd655ab3a92ea32ada7a21f618e63b93b1d4" @@ -4747,11 +4759,6 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-dynamic-import@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" - integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== - acorn-globals@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" @@ -4826,7 +4833,7 @@ acorn@^6.0.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== -acorn@^6.0.5, acorn@^6.2.1: +acorn@^6.2.1: version "6.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== @@ -7972,7 +7979,7 @@ chroma-js@^1.4.1: resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.4.1.tgz#eb2d9c4d1ff24616be84b35119f4d26f8205f134" integrity sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ== -chrome-trace-event@^1.0.0, chrome-trace-event@^1.0.2: +chrome-trace-event@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== @@ -10951,11 +10958,6 @@ enabled@1.0.x: dependencies: env-variable "0.0.x" -encode-uri-query@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/encode-uri-query/-/encode-uri-query-1.0.1.tgz#e9c70d3e1aab71b039e55b38a166013508803ba8" - integrity sha1-6ccNPhqrcbA55Vs4oWYBNQiAO6g= - encodeurl@^1.0.2, encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -11601,14 +11603,6 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -18285,16 +18279,11 @@ load-source-map@^1.0.0: semver "^5.3.0" source-map "^0.5.6" -loader-runner@^2.3.0, loader-runner@^2.4.0: +loader-runner@^2.3.1, loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== -loader-runner@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.1.tgz#026f12fe7c3115992896ac02ba022ba92971b979" - integrity sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw== - loader-utils@1.2.3, loader-utils@^1.0.4, loader-utils@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" @@ -19216,7 +19205,7 @@ memory-fs@^0.2.0: resolved "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" integrity sha1-8rslNovBIeORwlIN6Slpyu4KApA= -memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: +memory-fs@^0.4.0, memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= @@ -19320,7 +19309,7 @@ microevent.ts@~0.1.1: resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== -micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: +micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -20219,6 +20208,11 @@ node-forge@^0.7.6: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== +node-forge@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" + integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== + node-gyp@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" @@ -20292,7 +20286,7 @@ node-jose@1.1.0: util "^0.11.0" vm-browserify "0.0.4" -node-libs-browser@^2.0.0, node-libs-browser@^2.2.1: +node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== @@ -27362,7 +27356,7 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" -terser-webpack-plugin@^1.1.0, terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: +terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" integrity sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg== @@ -27391,35 +27385,16 @@ terser-webpack-plugin@^2.1.2: terser "^4.3.4" webpack-sources "^1.4.3" -terser@^4.1.2: - version "4.2.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.2.0.tgz#4b1b5f4424b426a7a47e80d6aae45e0d7979aef0" - integrity sha512-6lPt7lZdZ/13icQJp8XasFOwZjFJkxFFIb/N1fhYEQNoNI3Ilo3KABZ9OocZvZoB39r6SiIk/0+v/bt8nZoSeA== - dependencies: - commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - -terser@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.3.4.tgz#ad91bade95619e3434685d69efa621a5af5f877d" - integrity sha512-Kcrn3RiW8NtHBP0ssOAzwa2MsIRQ8lJWiBG/K7JgqPlomA3mtb2DEmp4/hrUA+Jujx+WZ02zqd7GYD+QRBB/2Q== +terser@^4.1.2, terser@^4.3.4: + version "4.6.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.1.tgz#913e35e0d38a75285a7913ba01d753c4089ebdbd" + integrity sha512-w0f2OWFD7ka3zwetgVAhNMeyzEbj39ht2Tb0qKflw9PmW9Qbo5tjTh01QJLkhO9t9RDDQYvk+WXqpECI2C6i2A== dependencies: commander "^2.20.0" source-map "~0.6.1" source-map-support "~0.5.12" -test-exclude@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.1.0.tgz#6ba6b25179d2d38724824661323b73e03c0c1de1" - integrity sha512-gwf0S2fFsANC55fSeSqpb8BYk6w3FDvwZxfNjeF6FRgvFa43r+7wRiA/Q0IxoRU37wB/LE8IQ4221BsNucTaCA== - dependencies: - arrify "^1.0.1" - minimatch "^3.0.4" - read-pkg-up "^4.0.0" - require-main-filename "^1.0.1" - -test-exclude@^5.2.3: +test-exclude@^5.0.0, test-exclude@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g== @@ -29938,7 +29913,7 @@ warning@^4.0.2: dependencies: loose-envify "^1.0.0" -watchpack@^1.5.0, watchpack@^1.6.0: +watchpack@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== @@ -30098,7 +30073,7 @@ webpack-sources@^1.1.0: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== @@ -30106,37 +30081,7 @@ webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack@4.33.0: - version "4.33.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.33.0.tgz#c30fc4307db432e5c5e3333aaa7c16a15a3b277e" - integrity sha512-ggWMb0B2QUuYso6FPZKUohOgfm+Z0sVFs8WwWuSH1IAvkWs428VDNmOlAxvHGTB9Dm/qOB/qtE5cRx5y01clxw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.0.5" - acorn-dynamic-import "^4.0.0" - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chrome-trace-event "^1.0.0" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.0" - json-parse-better-errors "^1.0.2" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - micromatch "^3.1.8" - mkdirp "~0.5.0" - neo-async "^2.5.0" - node-libs-browser "^2.0.0" - schema-utils "^1.0.0" - tapable "^1.1.0" - terser-webpack-plugin "^1.1.0" - watchpack "^1.5.0" - webpack-sources "^1.3.0" - -webpack@4.41.0, webpack@^4.41.0: +webpack@4.41.0, webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.0: version "4.41.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.0.tgz#db6a254bde671769f7c14e90a1a55e73602fc70b" integrity sha512-yNV98U4r7wX1VJAj5kyMsu36T8RPPQntcb5fJLOsMz/pt/WrKC0Vp1bAlqPLkA1LegSwQwf6P+kAbyhRKVQ72g== @@ -30165,35 +30110,6 @@ webpack@4.41.0, webpack@^4.41.0: watchpack "^1.6.0" webpack-sources "^1.4.1" -webpack@^4.33.0, webpack@^4.38.0: - version "4.39.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.39.3.tgz#a02179d1032156b713b6ec2da7e0df9d037def50" - integrity sha512-BXSI9M211JyCVc3JxHWDpze85CvjC842EvpRsVTc/d15YJGlox7GIDd38kJgWrb3ZluyvIjgenbLDMBQPDcxYQ== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.2.1" - ajv "^6.10.2" - ajv-keywords "^3.4.1" - chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.3" - json-parse-better-errors "^1.0.2" - loader-runner "^2.4.0" - loader-utils "^1.2.3" - memory-fs "^0.4.1" - micromatch "^3.1.10" - mkdirp "^0.5.1" - neo-async "^2.6.1" - node-libs-browser "^2.2.1" - schema-utils "^1.0.0" - tapable "^1.1.3" - terser-webpack-plugin "^1.4.1" - watchpack "^1.6.0" - webpack-sources "^1.4.1" - websocket-driver@>=0.5.1: version "0.7.0" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"