diff --git a/.ci/ci_groups.yml b/.ci/ci_groups.yml new file mode 100644 index 0000000000000..6d1fb2234406c --- /dev/null +++ b/.ci/ci_groups.yml @@ -0,0 +1,29 @@ +root: + - ciGroup1 + - ciGroup2 + - ciGroup3 + - ciGroup4 + - ciGroup5 + - ciGroup6 + - ciGroup7 + - ciGroup8 + - ciGroup9 + - ciGroup10 + - ciGroup11 + - ciGroup12 + +xpack: + - ciGroup1 + - ciGroup2 + - ciGroup3 + - ciGroup4 + - ciGroup5 + - ciGroup6 + - ciGroup7 + - ciGroup8 + - ciGroup9 + - ciGroup10 + - ciGroup11 + - ciGroup12 + - ciGroup13 + - ciGroupDocker diff --git a/.ci/jobs.yml b/.ci/jobs.yml deleted file mode 100644 index 1440c6870a86d..0000000000000 --- a/.ci/jobs.yml +++ /dev/null @@ -1,41 +0,0 @@ -# This file is needed by node scripts/ensure_all_tests_in_ci_group for the list of ciGroups. That must be changed before this file can be removed - -JOB: - - kibana-intake - - kibana-firefoxSmoke - - kibana-ciGroup1 - - kibana-ciGroup2 - - kibana-ciGroup3 - - kibana-ciGroup4 - - kibana-ciGroup5 - - kibana-ciGroup6 - - kibana-ciGroup7 - - kibana-ciGroup8 - - kibana-ciGroup9 - - kibana-ciGroup10 - - kibana-ciGroup11 - - kibana-ciGroup12 - - kibana-accessibility - - kibana-visualRegression - - # make sure all x-pack-ciGroups are listed in test/scripts/jenkins_xpack_ci_group.sh - - x-pack-firefoxSmoke - - x-pack-ciGroup1 - - x-pack-ciGroup2 - - x-pack-ciGroup3 - - x-pack-ciGroup4 - - x-pack-ciGroup5 - - x-pack-ciGroup6 - - x-pack-ciGroup7 - - x-pack-ciGroup8 - - x-pack-ciGroup9 - - x-pack-ciGroup10 - - x-pack-ciGroup11 - - x-pack-ciGroup12 - - x-pack-ciGroup13 - - x-pack-ciGroupDocker - - x-pack-accessibility - - x-pack-visualRegression - -# `~` is yaml for `null` -exclude: ~ diff --git a/docs/development/core/public/kibana-plugin-core-public.chromebrand.logo.md b/docs/development/core/public/kibana-plugin-core-public.chromebrand.logo.md deleted file mode 100644 index 561d9c50008b8..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromebrand.logo.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeBrand](./kibana-plugin-core-public.chromebrand.md) > [logo](./kibana-plugin-core-public.chromebrand.logo.md) - -## ChromeBrand.logo property - -Signature: - -```typescript -logo?: string; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromebrand.md b/docs/development/core/public/kibana-plugin-core-public.chromebrand.md deleted file mode 100644 index 21cdf6c3dee9b..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromebrand.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeBrand](./kibana-plugin-core-public.chromebrand.md) - -## ChromeBrand interface - - -Signature: - -```typescript -export interface ChromeBrand -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [logo](./kibana-plugin-core-public.chromebrand.logo.md) | string | | -| [smallLogo](./kibana-plugin-core-public.chromebrand.smalllogo.md) | string | | - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromebrand.smalllogo.md b/docs/development/core/public/kibana-plugin-core-public.chromebrand.smalllogo.md deleted file mode 100644 index 5b21e806540be..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromebrand.smalllogo.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeBrand](./kibana-plugin-core-public.chromebrand.md) > [smallLogo](./kibana-plugin-core-public.chromebrand.smalllogo.md) - -## ChromeBrand.smallLogo property - -Signature: - -```typescript -smallLogo?: string; -``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.addapplicationclass.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.addapplicationclass.md deleted file mode 100644 index 67e86863ad3c8..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.addapplicationclass.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [addApplicationClass](./kibana-plugin-core-public.chromestart.addapplicationclass.md) - -## ChromeStart.addApplicationClass() method - -Add a className that should be set on the application container. - -Signature: - -```typescript -addApplicationClass(className: string): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| className | string | | - -Returns: - -`void` - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.getapplicationclasses_.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.getapplicationclasses_.md deleted file mode 100644 index c932d8b7f0a40..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.getapplicationclasses_.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [getApplicationClasses$](./kibana-plugin-core-public.chromestart.getapplicationclasses_.md) - -## ChromeStart.getApplicationClasses$() method - -Get the current set of classNames that will be set on the application container. - -Signature: - -```typescript -getApplicationClasses$(): Observable; -``` -Returns: - -`Observable` - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.getbrand_.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.getbrand_.md deleted file mode 100644 index fa42defd6339a..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.getbrand_.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [getBrand$](./kibana-plugin-core-public.chromestart.getbrand_.md) - -## ChromeStart.getBrand$() method - -Get an observable of the current brand information. - -Signature: - -```typescript -getBrand$(): Observable; -``` -Returns: - -`Observable` - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.md index 2d465745c436b..7285b4a00a0ec 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromestart.md @@ -50,20 +50,14 @@ core.chrome.setHelpExtension(elem => { | Method | Description | | --- | --- | -| [addApplicationClass(className)](./kibana-plugin-core-public.chromestart.addapplicationclass.md) | Add a className that should be set on the application container. | -| [getApplicationClasses$()](./kibana-plugin-core-public.chromestart.getapplicationclasses_.md) | Get the current set of classNames that will be set on the application container. | | [getBadge$()](./kibana-plugin-core-public.chromestart.getbadge_.md) | Get an observable of the current badge | -| [getBrand$()](./kibana-plugin-core-public.chromestart.getbrand_.md) | Get an observable of the current brand information. | | [getBreadcrumbs$()](./kibana-plugin-core-public.chromestart.getbreadcrumbs_.md) | Get an observable of the current list of breadcrumbs | | [getBreadcrumbsAppendExtension$()](./kibana-plugin-core-public.chromestart.getbreadcrumbsappendextension_.md) | Get an observable of the current extension appended to breadcrumbs | | [getCustomNavLink$()](./kibana-plugin-core-public.chromestart.getcustomnavlink_.md) | Get an observable of the current custom nav link | | [getHelpExtension$()](./kibana-plugin-core-public.chromestart.gethelpextension_.md) | Get an observable of the current custom help conttent | | [getIsNavDrawerLocked$()](./kibana-plugin-core-public.chromestart.getisnavdrawerlocked_.md) | Get an observable of the current locked state of the nav drawer. | | [getIsVisible$()](./kibana-plugin-core-public.chromestart.getisvisible_.md) | Get an observable of the current visibility state of the chrome. | -| [removeApplicationClass(className)](./kibana-plugin-core-public.chromestart.removeapplicationclass.md) | Remove a className added with addApplicationClass(). If className is unknown it is ignored. | -| [setAppTitle(appTitle)](./kibana-plugin-core-public.chromestart.setapptitle.md) | Sets the current app's title | | [setBadge(badge)](./kibana-plugin-core-public.chromestart.setbadge.md) | Override the current badge | -| [setBrand(brand)](./kibana-plugin-core-public.chromestart.setbrand.md) | Set the brand configuration. | | [setBreadcrumbs(newBreadcrumbs)](./kibana-plugin-core-public.chromestart.setbreadcrumbs.md) | Override the current set of breadcrumbs | | [setBreadcrumbsAppendExtension(breadcrumbsAppendExtension)](./kibana-plugin-core-public.chromestart.setbreadcrumbsappendextension.md) | Mount an element next to the last breadcrumb | | [setCustomNavLink(newCustomNavLink)](./kibana-plugin-core-public.chromestart.setcustomnavlink.md) | Override the current set of custom nav link | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.removeapplicationclass.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.removeapplicationclass.md deleted file mode 100644 index 5bdeec635ed44..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.removeapplicationclass.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [removeApplicationClass](./kibana-plugin-core-public.chromestart.removeapplicationclass.md) - -## ChromeStart.removeApplicationClass() method - -Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. - -Signature: - -```typescript -removeApplicationClass(className: string): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| className | string | | - -Returns: - -`void` - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.setapptitle.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.setapptitle.md deleted file mode 100644 index f0e2db30f1891..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.setapptitle.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [setAppTitle](./kibana-plugin-core-public.chromestart.setapptitle.md) - -## ChromeStart.setAppTitle() method - -Sets the current app's title - -Signature: - -```typescript -setAppTitle(appTitle: string): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| appTitle | string | | - -Returns: - -`void` - diff --git a/docs/development/core/public/kibana-plugin-core-public.chromestart.setbrand.md b/docs/development/core/public/kibana-plugin-core-public.chromestart.setbrand.md deleted file mode 100644 index daaa510483ae7..0000000000000 --- a/docs/development/core/public/kibana-plugin-core-public.chromestart.setbrand.md +++ /dev/null @@ -1,39 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeStart](./kibana-plugin-core-public.chromestart.md) > [setBrand](./kibana-plugin-core-public.chromestart.setbrand.md) - -## ChromeStart.setBrand() method - -Set the brand configuration. - -Signature: - -```typescript -setBrand(brand: ChromeBrand): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| brand | ChromeBrand | | - -Returns: - -`void` - -## Remarks - -Normally the `logo` property will be rendered as the CSS background for the home link in the chrome navigation, but when the page is rendered in a small window the `smallLogo` will be used and rendered at about 45px wide. - -## Example - - -```js -chrome.setBrand({ - logo: 'url(/plugins/app/logo.png) center no-repeat' - smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' -}) - -``` - diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index e984fbb675e6d..59735b053adbc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -42,7 +42,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AsyncPlugin](./kibana-plugin-core-public.asyncplugin.md) | A plugin with asynchronous lifecycle methods. | | [Capabilities](./kibana-plugin-core-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-core-public.chromebadge.md) | | -| [ChromeBrand](./kibana-plugin-core-public.chromebrand.md) | | | [ChromeDocTitle](./kibana-plugin-core-public.chromedoctitle.md) | APIs for accessing and updating the document title. | | [ChromeHelpExtension](./kibana-plugin-core-public.chromehelpextension.md) | | | [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.basepath.get.md b/docs/development/core/server/kibana-plugin-core-server.basepath.get.md index f1e71616d4d17..b35c6e657b01f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.basepath.get.md +++ b/docs/development/core/server/kibana-plugin-core-server.basepath.get.md @@ -9,5 +9,5 @@ returns `basePath` value, specific for an incoming request. Signature: ```typescript -get: (request: KibanaRequest | LegacyRequest) => string; +get: (request: KibanaRequest) => string; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.basepath.md b/docs/development/core/server/kibana-plugin-core-server.basepath.md index 54ab029d987a7..f4bac88cd85f5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.basepath.md +++ b/docs/development/core/server/kibana-plugin-core-server.basepath.md @@ -20,10 +20,10 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | -| [get](./kibana-plugin-core-server.basepath.get.md) | | (request: KibanaRequest | LegacyRequest) => string | returns basePath value, specific for an incoming request. | +| [get](./kibana-plugin-core-server.basepath.get.md) | | (request: KibanaRequest) => string | returns basePath value, specific for an incoming request. | | [prepend](./kibana-plugin-core-server.basepath.prepend.md) | | (path: string) => string | Prepends path with the basePath. | | [publicBaseUrl](./kibana-plugin-core-server.basepath.publicbaseurl.md) | | string | The server's publicly exposed base URL, if configured. Includes protocol, host, port (optional) and the [BasePath.serverBasePath](./kibana-plugin-core-server.basepath.serverbasepath.md). | | [remove](./kibana-plugin-core-server.basepath.remove.md) | | (path: string) => string | Removes the prepended basePath from the path. | | [serverBasePath](./kibana-plugin-core-server.basepath.serverbasepath.md) | | string | returns the server's basePathSee [BasePath.get](./kibana-plugin-core-server.basepath.get.md) for getting the basePath value for a specific request | -| [set](./kibana-plugin-core-server.basepath.set.md) | | (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | +| [set](./kibana-plugin-core-server.basepath.set.md) | | (request: KibanaRequest, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | diff --git a/docs/development/core/server/kibana-plugin-core-server.basepath.set.md b/docs/development/core/server/kibana-plugin-core-server.basepath.set.md index eb355f134d562..b90767022d594 100644 --- a/docs/development/core/server/kibana-plugin-core-server.basepath.set.md +++ b/docs/development/core/server/kibana-plugin-core-server.basepath.set.md @@ -9,5 +9,5 @@ sets `basePath` value, specific for an incoming request. Signature: ```typescript -set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; +set: (request: KibanaRequest, requestSpecificBasePath: string) => void; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.getauthheaders.md b/docs/development/core/server/kibana-plugin-core-server.getauthheaders.md index c66aead4dfa9c..2f1502a5ea0ea 100644 --- a/docs/development/core/server/kibana-plugin-core-server.getauthheaders.md +++ b/docs/development/core/server/kibana-plugin-core-server.getauthheaders.md @@ -9,5 +9,5 @@ Get headers to authenticate a user against Elasticsearch. Signature: ```typescript -export declare type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; +export declare type GetAuthHeaders = (request: KibanaRequest) => AuthHeaders | undefined; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.getauthstate.md b/docs/development/core/server/kibana-plugin-core-server.getauthstate.md index 0fa8d745d3a44..979a6b5b5792b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.getauthstate.md +++ b/docs/development/core/server/kibana-plugin-core-server.getauthstate.md @@ -9,7 +9,7 @@ Gets authentication state for a request. Returned by `auth` interceptor. Signature: ```typescript -export declare type GetAuthState = (request: KibanaRequest | LegacyRequest) => { +export declare type GetAuthState = (request: KibanaRequest) => { status: AuthStatus; state: T; }; diff --git a/docs/development/core/server/kibana-plugin-core-server.isauthenticated.md b/docs/development/core/server/kibana-plugin-core-server.isauthenticated.md index de839619f3ac7..0c2d7fab8b579 100644 --- a/docs/development/core/server/kibana-plugin-core-server.isauthenticated.md +++ b/docs/development/core/server/kibana-plugin-core-server.isauthenticated.md @@ -9,5 +9,5 @@ Returns authentication status for a request. Signature: ```typescript -export declare type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolean; +export declare type IsAuthenticated = (request: KibanaRequest) => boolean; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyrequest.md b/docs/development/core/server/kibana-plugin-core-server.legacyrequest.md deleted file mode 100644 index 59d4fda87dc18..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.legacyrequest.md +++ /dev/null @@ -1,16 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [LegacyRequest](./kibana-plugin-core-server.legacyrequest.md) - -## LegacyRequest interface - -> Warning: This API is now obsolete. -> -> `hapi` request object, supported during migration process only for backward compatibility. -> - -Signature: - -```typescript -export interface LegacyRequest extends Request -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index ba4f528352566..96bb82c8968df 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -104,7 +104,6 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IUiSettingsClient](./kibana-plugin-core-server.iuisettingsclient.md) | Server-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. | | [KibanaRequestEvents](./kibana-plugin-core-server.kibanarequestevents.md) | Request events. | | [KibanaRequestRoute](./kibana-plugin-core-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | -| [LegacyRequest](./kibana-plugin-core-server.legacyrequest.md) | | | [LoggerContextConfigInput](./kibana-plugin-core-server.loggercontextconfiginput.md) | | | [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) | Provides APIs to plugins for customizing the plugin's logger. | | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | APIs to retrieves metrics gathered and exposed by the core platform. | @@ -127,7 +126,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PrebootPlugin](./kibana-plugin-core-server.prebootplugin.md) | The interface that should be returned by a PluginInitializer for a preboot plugin. | | [PrebootServicePreboot](./kibana-plugin-core-server.prebootservicepreboot.md) | Kibana Preboot Service allows to control the boot flow of Kibana. Preboot plugins can use it to hold the boot until certain condition is met. | | [RegisterDeprecationsConfig](./kibana-plugin-core-server.registerdeprecationsconfig.md) | | -| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | +| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request | | [ResolveCapabilitiesOptions](./kibana-plugin-core-server.resolvecapabilitiesoptions.md) | Defines a set of additional options for the resolveCapabilities method of [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md). | | [RouteConfig](./kibana-plugin-core-server.routeconfig.md) | Route specific configuration. | | [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) | Additional route options. | diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index b6d78f8890b37..15a2e235fff29 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -6,7 +6,7 @@ Plugin specific context passed to a route handler. -Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request +Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.scopeablerequest.md b/docs/development/core/server/kibana-plugin-core-server.scopeablerequest.md index 67abc483e1f65..f8f05823ae81f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.scopeablerequest.md +++ b/docs/development/core/server/kibana-plugin-core-server.scopeablerequest.md @@ -11,5 +11,5 @@ See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). Signature: ```typescript -export declare type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest; +export declare type ScopeableRequest = KibanaRequest | FakeRequest; ``` diff --git a/package.json b/package.json index 77836c8e29c98..6d604bc20cadd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "homepage": "https://www.elastic.co/products/kibana", "bugs": { - "url": "http://github.com/elastic/kibana/issues" + "url": "https://github.com/elastic/kibana/issues" }, "kibana": { "clean": { diff --git a/packages/kbn-plugin-generator/.babelrc b/packages/kbn-plugin-generator/.babelrc new file mode 100644 index 0000000000000..7da72d1779128 --- /dev/null +++ b/packages/kbn-plugin-generator/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["@kbn/babel-preset/node_preset"] +} diff --git a/packages/kbn-plugin-generator/BUILD.bazel b/packages/kbn-plugin-generator/BUILD.bazel index c16862ee4f3c2..c935d1763dae8 100644 --- a/packages/kbn-plugin-generator/BUILD.bazel +++ b/packages/kbn-plugin-generator/BUILD.bazel @@ -1,5 +1,6 @@ load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("//src/dev/bazel:index.bzl", "jsts_transpiler") PKG_BASE_NAME = "kbn-plugin-generator" PKG_REQUIRE_NAME = "@kbn/plugin-generator" @@ -35,7 +36,7 @@ NPM_MODULE_EXTRA_FILES = [ ":template", ] -SRC_DEPS = [ +RUNTIME_DEPS = [ "//packages/kbn-utils", "//packages/kbn-dev-utils", "@npm//del", @@ -49,6 +50,11 @@ SRC_DEPS = [ ] TYPES_DEPS = [ + "//packages/kbn-utils", + "//packages/kbn-dev-utils", + "@npm//del", + "@npm//execa", + "@npm//globby", "@npm//@types/ejs", "@npm//@types/inquirer", "@npm//@types/jest", @@ -58,7 +64,11 @@ TYPES_DEPS = [ "@npm//@types/vinyl-fs", ] -DEPS = SRC_DEPS + TYPES_DEPS +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) ts_config( name = "tsconfig", @@ -70,13 +80,14 @@ ts_config( ) ts_project( - name = "tsc", + name = "tsc_types", args = ['--pretty'], srcs = SRCS, - deps = DEPS, + deps = TYPES_DEPS, declaration = True, declaration_map = True, - out_dir = "target", + emit_declaration_only = True, + out_dir = "target_types", source_map = True, root_dir = "src", tsconfig = ":tsconfig", @@ -85,7 +96,7 @@ ts_project( js_library( name = PKG_BASE_NAME, srcs = NPM_MODULE_EXTRA_FILES, - deps = DEPS + [":tsc"], + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], package_name = PKG_REQUIRE_NAME, visibility = ["//visibility:public"], ) diff --git a/packages/kbn-plugin-generator/package.json b/packages/kbn-plugin-generator/package.json index 298373afd2f24..d30f25e478dcf 100644 --- a/packages/kbn-plugin-generator/package.json +++ b/packages/kbn-plugin-generator/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "license": "SSPL-1.0 OR Elastic License 2.0", - "main": "target/index.js", - "types": "target/index.d.ts" + "main": "target_node/index.js", + "types": "target_types/index.d.ts" } \ No newline at end of file diff --git a/packages/kbn-plugin-generator/tsconfig.json b/packages/kbn-plugin-generator/tsconfig.json index 6a25803c83940..5b666cf801da6 100644 --- a/packages/kbn-plugin-generator/tsconfig.json +++ b/packages/kbn-plugin-generator/tsconfig.json @@ -1,12 +1,13 @@ { "extends": "../../tsconfig.bazel.json", "compilerOptions": { - "outDir": "target", - "target": "ES2019", "declaration": true, "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "target_types", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-plugin-generator/src", + "target": "ES2019", "types": [ "jest", "node" diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index abc5cfa8efaa8..0199aa6e311b6 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -104,10 +104,10 @@ module.exports = { collectCoverageFrom: [ '**/*.{js,mjs,jsx,ts,tsx}', '!**/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*', - '!**/*mock*.ts', - '!**/*.test.ts', + '!**/*mock*.{ts,tsx}', + '!**/*.test.{ts,tsx}', '!**/*.d.ts', - '!**/index.{js,ts}', + '!**/index.{js,ts,tsx}', ], // A custom resolver to preserve symlinks by default diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index 37bb465e8e5b7..7fae313c68bd3 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -71,6 +71,7 @@ const defaultRelativeToConfigPath = (path: string) => { export const schema = Joi.object() .keys({ + rootTags: Joi.array().items(Joi.string()), testFiles: Joi.array().items(Joi.string()), testRunner: Joi.func(), diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js index 3832dc7c59e19..c6693245da28b 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/decorate_mocha_ui.js @@ -21,7 +21,7 @@ function split(arr, fn) { return [a, b]; } -export function decorateMochaUi(log, lifecycle, context, { isDockerGroup }) { +export function decorateMochaUi(log, lifecycle, context, { isDockerGroup, rootTags }) { // incremented at the start of each suite, decremented after // so that in each non-suite call we can know if we are within // a suite, or that when a suite is defined it is within a suite @@ -62,7 +62,13 @@ export function decorateMochaUi(log, lifecycle, context, { isDockerGroup }) { }); const relativeFilePath = relative(REPO_ROOT, this.file); - this._tags = isDockerGroup ? ['ciGroupDocker', relativeFilePath] : [relativeFilePath]; + this._tags = [ + ...(isDockerGroup ? ['ciGroupDocker', relativeFilePath] : [relativeFilePath]), + // we attach the "root tags" to all the child suites of the root suite, so that if they + // need to be excluded they can be removed from the root suite without removing the entire + // root suite + ...(this.parent.root ? [...(rootTags ?? [])] : []), + ]; this.suiteTag = relativeFilePath; // The tag that uniquely targets this suite/file this.tags = (tags) => { const newTags = Array.isArray(tags) ? tags : [tags]; diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/load_test_files.js b/packages/kbn-test/src/functional_test_runner/lib/mocha/load_test_files.js index b6cc73cdb08c8..59f8f003004b6 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/load_test_files.js +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/load_test_files.js @@ -62,6 +62,7 @@ export const loadTestFiles = ({ const context = decorateMochaUi(log, lifecycle, global, { isDockerGroup, + rootTags: config.get('rootTags'), }); mocha.suite.emit('pre-require', context, path, mocha); diff --git a/packages/kbn-test/src/functional_tests/lib/run_ftr.js b/packages/kbn-test/src/functional_tests/lib/run_ftr.js index b84d01fbebbe1..40937b8b4fc2d 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_ftr.js +++ b/packages/kbn-test/src/functional_tests/lib/run_ftr.js @@ -52,9 +52,9 @@ export async function assertNoneExcluded({ configPath, options }) { throw new CliError(` ${stats.excludedTests.length} tests in the ${configPath} config are excluded when filtering by the tags run on CI. Make sure that all suites are - tagged with one of the following tags, or extend the list of tags in test/scripts/jenkins_xpack.sh + tagged with one of the following tags: - tags: ${JSON.stringify(options.suiteTags)} + ${JSON.stringify(options.suiteTags)} - ${stats.excludedTests.join('\n - ')} `); diff --git a/packages/kbn-test/src/functional_tests/tasks.js b/packages/kbn-test/src/functional_tests/tasks.js index 00b04fcda26db..4d12fb5ea5ec1 100644 --- a/packages/kbn-test/src/functional_tests/tasks.js +++ b/packages/kbn-test/src/functional_tests/tasks.js @@ -55,7 +55,7 @@ const makeSuccessMessage = (options) => { * @property {string} options.esFrom Optionally run from source instead of snapshot */ export async function runTests(options) { - if (!process.env.KBN_NP_PLUGINS_BUILT) { + if (!process.env.KBN_NP_PLUGINS_BUILT && !options.assertNoneExcluded) { const log = options.createLogger(); log.warning('❗️❗️❗️'); log.warning('❗️❗️❗️'); diff --git a/scripts/ensure_all_tests_in_ci_group.js b/scripts/ensure_all_tests_in_ci_group.js index 757e2b28c75e3..7e382c4a20d17 100644 --- a/scripts/ensure_all_tests_in_ci_group.js +++ b/scripts/ensure_all_tests_in_ci_group.js @@ -7,4 +7,4 @@ */ require('../src/setup_node_env'); -require('../src/dev/run_ensure_all_tests_in_ci_group'); +require('../src/dev/ensure_all_tests_in_ci_group').runEnsureAllTestsInCiGroupsCli(); diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index b624084258817..5e29218250fb9 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; -import { ChromeBadge, ChromeBrand, ChromeBreadcrumb, ChromeService, InternalChromeStart } from './'; +import { ChromeBadge, ChromeBreadcrumb, ChromeService, InternalChromeStart } from './'; const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { @@ -40,14 +40,8 @@ const createStartContractMock = () => { getCenter$: jest.fn(), getRight$: jest.fn(), }, - setAppTitle: jest.fn(), - setBrand: jest.fn(), - getBrand$: jest.fn(), setIsVisible: jest.fn(), getIsVisible$: jest.fn(), - addApplicationClass: jest.fn(), - removeApplicationClass: jest.fn(), - getApplicationClasses$: jest.fn(), getBadge$: jest.fn(), setBadge: jest.fn(), getBreadcrumbs$: jest.fn(), @@ -64,9 +58,7 @@ const createStartContractMock = () => { getBodyClasses$: jest.fn(), }; startContract.navLinks.getAll.mockReturnValue([]); - startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand)); startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); - startContract.getApplicationClasses$.mockReturnValue(new BehaviorSubject(['class-name'])); startContract.getBadge$.mockReturnValue(new BehaviorSubject({} as ChromeBadge)); startContract.getBreadcrumbs$.mockReturnValue(new BehaviorSubject([{} as ChromeBreadcrumb])); startContract.getBreadcrumbsAppendExtension$.mockReturnValue(new BehaviorSubject(undefined)); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 92f5a854f6b00..8df8d76a13c46 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -23,8 +23,10 @@ import { getAppInfo } from '../application/utils'; class FakeApp implements App { public title = `${this.id} App`; public mount = () => () => {}; + constructor(public id: string, public chromeless?: boolean) {} } + const store = new Map(); const originalLocalStorage = window.localStorage; @@ -170,36 +172,6 @@ describe('start', () => { }); }); - describe('brand', () => { - it('updates/emits the brand as it changes', async () => { - const { chrome, service } = await start(); - const promise = chrome.getBrand$().pipe(toArray()).toPromise(); - - chrome.setBrand({ - logo: 'big logo', - smallLogo: 'not so big logo', - }); - chrome.setBrand({ - logo: 'big logo without small logo', - }); - service.stop(); - - await expect(promise).resolves.toMatchInlineSnapshot(` - Array [ - Object {}, - Object { - "logo": "big logo", - "smallLogo": "not so big logo", - }, - Object { - "logo": "big logo without small logo", - "smallLogo": undefined, - }, - ] - `); - }); - }); - describe('visibility', () => { it('emits false when no application is mounted', async () => { const { chrome, service } = await start(); @@ -289,54 +261,6 @@ describe('start', () => { }); }); - describe('application classes', () => { - it('updates/emits the application classes', async () => { - const { chrome, service } = await start(); - const promise = chrome.getApplicationClasses$().pipe(toArray()).toPromise(); - - chrome.addApplicationClass('foo'); - chrome.addApplicationClass('foo'); - chrome.addApplicationClass('bar'); - chrome.addApplicationClass('bar'); - chrome.addApplicationClass('baz'); - chrome.removeApplicationClass('bar'); - chrome.removeApplicationClass('foo'); - service.stop(); - - await expect(promise).resolves.toMatchInlineSnapshot(` - Array [ - Array [], - Array [ - "foo", - ], - Array [ - "foo", - ], - Array [ - "foo", - "bar", - ], - Array [ - "foo", - "bar", - ], - Array [ - "foo", - "bar", - "baz", - ], - Array [ - "foo", - "baz", - ], - Array [ - "baz", - ], - ] - `); - }); - }); - describe('badge', () => { it('updates/emits the current badge', async () => { const { chrome, service } = await start(); @@ -407,7 +331,9 @@ describe('start', () => { const { chrome, service } = await start(); const promise = chrome.getBreadcrumbsAppendExtension$().pipe(toArray()).toPromise(); - chrome.setBreadcrumbsAppendExtension({ content: (element) => () => {} }); + chrome.setBreadcrumbsAppendExtension({ + content: (element) => () => {}, + }); service.stop(); await expect(promise).resolves.toMatchInlineSnapshot(` @@ -521,14 +447,12 @@ describe('start', () => { describe('stop', () => { it('completes applicationClass$, getIsNavDrawerLocked, breadcrumbs$, isVisible$, and brand$ observables', async () => { const { chrome, service } = await start(); - const promise = Rx.combineLatest( - chrome.getBrand$(), - chrome.getApplicationClasses$(), + const promise = Rx.combineLatest([ chrome.getIsNavDrawerLocked$(), chrome.getBreadcrumbs$(), chrome.getIsVisible$(), - chrome.getHelpExtension$() - ).toPromise(); + chrome.getHelpExtension$(), + ]).toPromise(); service.stop(); await promise; @@ -539,14 +463,12 @@ describe('stop', () => { service.stop(); await expect( - Rx.combineLatest( - chrome.getBrand$(), - chrome.getApplicationClasses$(), + Rx.combineLatest([ chrome.getIsNavDrawerLocked$(), chrome.getBreadcrumbs$(), chrome.getIsVisible$(), - chrome.getHelpExtension$() - ).toPromise() + chrome.getHelpExtension$(), + ]).toPromise() ).resolves.toBe(undefined); }); }); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index f1381c52ce779..5740e1739280a 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -26,7 +26,6 @@ import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_acce import { Header } from './ui'; import { ChromeBadge, - ChromeBrand, ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension, ChromeHelpExtension, @@ -105,9 +104,6 @@ export class ChromeService { }: StartDeps): Promise { this.initVisibility(application); - const appTitle$ = new BehaviorSubject('Kibana'); - const brand$ = new BehaviorSubject({}); - const applicationClasses$ = new BehaviorSubject>(new Set()); const helpExtension$ = new BehaviorSubject(undefined); const breadcrumbs$ = new BehaviorSubject([]); const breadcrumbsAppendExtension$ = new BehaviorSubject< @@ -210,7 +206,6 @@ export class ChromeService {
), - setAppTitle: (appTitle: string) => appTitle$.next(appTitle), - - getBrand$: () => brand$.pipe(takeUntil(this.stop$)), - - setBrand: (brand: ChromeBrand) => { - brand$.next( - Object.freeze({ - logo: brand.logo, - smallLogo: brand.smallLogo, - }) - ); - }, - getIsVisible$: () => this.isVisible$, setIsVisible: (isVisible: boolean) => this.isForceHidden$.next(!isVisible), - getApplicationClasses$: () => - applicationClasses$.pipe( - map((set) => [...set]), - takeUntil(this.stop$) - ), - - addApplicationClass: (className: string) => { - const update = new Set([...applicationClasses$.getValue()]); - update.add(className); - applicationClasses$.next(update); - }, - - removeApplicationClass: (className: string) => { - const update = new Set([...applicationClasses$.getValue()]); - update.delete(className); - applicationClasses$.next(update); - }, - getBadge$: () => badge$.pipe(takeUntil(this.stop$)), setBadge: (badge: ChromeBadge) => { diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index dd7affcdbf7cd..b1a70c1dc2b04 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -29,7 +29,6 @@ export type { ChromeHelpExtension, ChromeBreadcrumbsAppendExtension, ChromeBreadcrumb, - ChromeBrand, ChromeBadge, ChromeUserBanner, } from './types'; diff --git a/src/core/public/chrome/types.ts b/src/core/public/chrome/types.ts index 732236f1ba4a1..813f385fc94d2 100644 --- a/src/core/public/chrome/types.ts +++ b/src/core/public/chrome/types.ts @@ -22,12 +22,6 @@ export interface ChromeBadge { iconType?: IconType; } -/** @public */ -export interface ChromeBrand { - logo?: string; - smallLogo?: string; -} - /** @public */ export type ChromeBreadcrumb = EuiBreadcrumb; @@ -93,40 +87,6 @@ export interface ChromeStart { /** {@inheritdoc ChromeDocTitle} */ docTitle: ChromeDocTitle; - /** - * Sets the current app's title - * - * @internalRemarks - * This should be handled by the application service once it is in charge - * of mounting applications. - */ - setAppTitle(appTitle: string): void; - - /** - * Get an observable of the current brand information. - */ - getBrand$(): Observable; - - /** - * Set the brand configuration. - * - * @remarks - * Normally the `logo` property will be rendered as the - * CSS background for the home link in the chrome navigation, but when the page is - * rendered in a small window the `smallLogo` will be used and rendered at about - * 45px wide. - * - * @example - * ```js - * chrome.setBrand({ - * logo: 'url(/plugins/app/logo.png) center no-repeat' - * smallLogo: 'url(/plugins/app/logo-small.png) center no-repeat' - * }) - * ``` - * - */ - setBrand(brand: ChromeBrand): void; - /** * Get an observable of the current visibility state of the chrome. */ @@ -139,21 +99,6 @@ export interface ChromeStart { */ setIsVisible(isVisible: boolean): void; - /** - * Get the current set of classNames that will be set on the application container. - */ - getApplicationClasses$(): Observable; - - /** - * Add a className that should be set on the application container. - */ - addApplicationClass(className: string): void; - - /** - * Remove a className added with `addApplicationClass()`. If className is unknown it is ignored. - */ - removeApplicationClass(className: string): void; - /** * Get an observable of the current badge */ @@ -232,6 +177,7 @@ export interface InternalChromeStart extends ChromeStart { * @internal */ getHeaderComponent(): JSX.Element; + /** * Used only by the rendering service to retrieve the set of classNames * that will be set on the body element. diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index d2bc11f4db877..4450533090c7f 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -9,45 +9,7 @@ exports[`Header renders 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], + "observers": Array [], "thrownError": null, } } @@ -4713,55 +4675,6 @@ exports[`Header renders 1`] = ` + + + Kibana + + + +`; diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index a401195b38942..578c87411e543 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -52,7 +52,6 @@ export interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; headerBanner$: Observable; - appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; breadcrumbsAppendExtension$: Observable; @@ -102,9 +101,7 @@ export function Header({ const toggleCollapsibleNavRef = createRef void }>(); const className = classnames('hide-for-sharing', 'headerGlobalNav'); - const Breadcrumbs = ( - - ); + const Breadcrumbs = ; return ( <> diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx index 26b397229d7e9..7d40bd77e2548 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx @@ -15,9 +15,7 @@ import { HeaderBreadcrumbs } from './header_breadcrumbs'; describe('HeaderBreadcrumbs', () => { it('renders updates to the breadcrumbs$ observable', () => { const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]); - const wrapper = mount( - - ); + const wrapper = mount(); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); act(() => breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }])); diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 0e2bae82a3ad3..a90ceed32dcce 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -14,17 +14,15 @@ import { Observable } from 'rxjs'; import { ChromeBreadcrumb } from '../../types'; interface Props { - appTitle$: Observable; breadcrumbs$: Observable; } -export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$ }: Props) { - const appTitle = useObservable(appTitle$, 'Kibana'); +export function HeaderBreadcrumbs({ breadcrumbs$ }: Props) { const breadcrumbs = useObservable(breadcrumbs$, []); let crumbs = breadcrumbs; - if (breadcrumbs.length === 0 && appTitle) { - crumbs = [{ text: appTitle }]; + if (breadcrumbs.length === 0) { + crumbs = [{ text: 'Kibana' }]; } crumbs = crumbs.map((breadcrumb, i) => ({ diff --git a/src/core/public/index.ts b/src/core/public/index.ts index e6e6433291873..d343a0b081fa1 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -28,7 +28,6 @@ import './index.scss'; import { ChromeBadge, - ChromeBrand, ChromeBreadcrumb, ChromeHelpExtension, ChromeHelpExtensionMenuLink, @@ -287,7 +286,6 @@ export interface CoreStart { export type { Capabilities, ChromeBadge, - ChromeBrand, ChromeBreadcrumb, ChromeHelpExtension, ChromeHelpExtensionMenuLink, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index d3f9ce71379b7..2217b71d2f1a3 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -231,14 +231,6 @@ export interface ChromeBadge { tooltip: string; } -// @public (undocumented) -export interface ChromeBrand { - // (undocumented) - logo?: string; - // (undocumented) - smallLogo?: string; -} - // @public (undocumented) export type ChromeBreadcrumb = EuiBreadcrumb; @@ -355,11 +347,8 @@ export interface ChromeRecentlyAccessedHistoryItem { // @public export interface ChromeStart { - addApplicationClass(className: string): void; docTitle: ChromeDocTitle; - getApplicationClasses$(): Observable; getBadge$(): Observable; - getBrand$(): Observable; getBreadcrumbs$(): Observable; // Warning: (ae-forgotten-export) The symbol "ChromeBreadcrumbsAppendExtension" needs to be exported by the entry point index.d.ts getBreadcrumbsAppendExtension$(): Observable; @@ -370,10 +359,7 @@ export interface ChromeStart { 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; setBreadcrumbsAppendExtension(breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension): void; setCustomNavLink(newCustomNavLink?: Partial): void; diff --git a/src/core/public/rendering/app_containers.test.tsx b/src/core/public/rendering/app_containers.test.tsx index 193e393f268f0..10f5f3f1c138f 100644 --- a/src/core/public/rendering/app_containers.test.tsx +++ b/src/core/public/rendering/app_containers.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import React from 'react'; @@ -17,11 +17,7 @@ describe('AppWrapper', () => { it('toggles the `hidden-chrome` class depending on the chrome visibility state', () => { const chromeVisible$ = new BehaviorSubject(true); - const component = mount( - - app-content - - ); + const component = mount(app-content); expect(component.getDOMNode()).toMatchInlineSnapshot(`
{
`); }); - - it('adds classes supplied by chrome', () => { - const chromeVisible$ = new BehaviorSubject(true); - const appClasses$ = new BehaviorSubject([]); - - const component = mount( - - app-content - - ); - expect(component.getDOMNode()).toMatchInlineSnapshot(` -
- app-content -
- `); - - act(() => appClasses$.next(['classA', 'classB'])); - component.update(); - expect(component.getDOMNode()).toMatchInlineSnapshot(` -
- app-content -
- `); - - act(() => appClasses$.next(['classC'])); - component.update(); - expect(component.getDOMNode()).toMatchInlineSnapshot(` -
- app-content -
- `); - - act(() => appClasses$.next([])); - component.update(); - expect(component.getDOMNode()).toMatchInlineSnapshot(` -
- app-content -
- `); - }); }); diff --git a/src/core/public/rendering/app_containers.tsx b/src/core/public/rendering/app_containers.tsx index 64d64d2caad75..2a8e944205910 100644 --- a/src/core/public/rendering/app_containers.tsx +++ b/src/core/public/rendering/app_containers.tsx @@ -14,18 +14,10 @@ import { APP_WRAPPER_CLASS } from '../../utils'; export const AppWrapper: React.FunctionComponent<{ chromeVisible$: Observable; - classes$: Observable; -}> = ({ chromeVisible$, classes$, children }) => { +}> = ({ chromeVisible$, children }) => { const visible = useObservable(chromeVisible$); - const classes = useObservable(classes$, ['']); return ( -
+
{children}
); diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index d9eb764fc9f0d..bdca628b295c6 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -13,7 +13,7 @@ import { RenderingService } from './rendering_service'; import { applicationServiceMock } from '../application/application_service.mock'; import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; -import { BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; describe('RenderingService#start', () => { let application: ReturnType; @@ -28,7 +28,6 @@ describe('RenderingService#start', () => { chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); - chrome.getApplicationClasses$.mockReturnValue(of([])); overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); @@ -78,26 +77,6 @@ describe('RenderingService#start', () => { expect(appWrapper.className).toEqual('kbnAppWrapper'); }); - it('adds the application classes to the AppWrapper', () => { - const applicationClasses$ = new BehaviorSubject([]); - const isVisible$ = new BehaviorSubject(true); - chrome.getIsVisible$.mockReturnValue(isVisible$); - chrome.getApplicationClasses$.mockReturnValue(applicationClasses$); - startService(); - - const appContainer = targetDomElement.querySelector('div.kbnAppWrapper')!; - expect(appContainer.className).toEqual('kbnAppWrapper'); - - act(() => applicationClasses$.next(['classA', 'classB'])); - expect(appContainer.className).toEqual('kbnAppWrapper classA classB'); - - act(() => applicationClasses$.next(['classC'])); - expect(appContainer.className).toEqual('kbnAppWrapper classC'); - - act(() => applicationClasses$.next([])); - expect(appContainer.className).toEqual('kbnAppWrapper'); - }); - it('contains wrapper divs', () => { startService(); expect(targetDomElement.querySelector('div.kbnAppWrapper')).toBeDefined(); diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 1dfb4259d7d70..d3f91851370d5 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -56,10 +56,7 @@ export class RenderingService {
{bannerComponent}
{/* The App Wrapper outside of the fixed headers that accepts custom class names from apps */} - + {/* Affixes a div to restrict the position of charts tooltip to the visible viewport minus the header */}
diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 89a7d752f7912..1b149cebfa957 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -8,7 +8,7 @@ import { Observable } from 'rxjs'; import { Headers } from '../http/router'; -import { LegacyRequest, KibanaRequest } from '../http'; +import { KibanaRequest } from '../http'; import { ElasticsearchConfig } from './elasticsearch_config'; import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client'; import { NodesVersionCompatibility } from './version_check/ensure_es_version'; @@ -156,7 +156,7 @@ export interface FakeRequest { * @public * See {@link KibanaRequest}. */ -export type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest; +export type ScopeableRequest = KibanaRequest | FakeRequest; /** * A limited set of Elasticsearch configuration entries exposed to the `preboot` plugins at `setup`. diff --git a/src/core/server/http/auth_headers_storage.test.ts b/src/core/server/http/auth_headers_storage.test.ts index b4269a4401f50..6fb2281c81750 100644 --- a/src/core/server/http/auth_headers_storage.test.ts +++ b/src/core/server/http/auth_headers_storage.test.ts @@ -7,33 +7,15 @@ */ import { AuthHeadersStorage } from './auth_headers_storage'; -import { KibanaRequest } from './router'; import { httpServerMock } from './http_server.mocks'; describe('AuthHeadersStorage', () => { describe('stores authorization headers', () => { it('retrieves a copy of headers associated with Kibana request', () => { const headers = { authorization: 'token' }; const storage = new AuthHeadersStorage(); - const rawRequest = httpServerMock.createRawRequest(); - storage.set(KibanaRequest.from(rawRequest), headers); - expect(storage.get(KibanaRequest.from(rawRequest))).toEqual(headers); - }); - - it('retrieves a copy of headers associated with Legacy.Request', () => { - const headers = { authorization: 'token' }; - const storage = new AuthHeadersStorage(); - const rawRequest = httpServerMock.createRawRequest(); - storage.set(rawRequest, headers); - expect(storage.get(rawRequest)).toEqual(headers); - }); - - it('retrieves a copy of headers associated with both KibanaRequest & Legacy.Request', () => { - const headers = { authorization: 'token' }; - const storage = new AuthHeadersStorage(); - const rawRequest = httpServerMock.createRawRequest(); - - storage.set(KibanaRequest.from(rawRequest), headers); - expect(storage.get(rawRequest)).toEqual(headers); + const request = httpServerMock.createKibanaRequest(); + storage.set(request, headers); + expect(storage.get(request)).toEqual(headers); }); }); }); diff --git a/src/core/server/http/auth_headers_storage.ts b/src/core/server/http/auth_headers_storage.ts index 34799ed753689..cddf84bfa8caf 100644 --- a/src/core/server/http/auth_headers_storage.ts +++ b/src/core/server/http/auth_headers_storage.ts @@ -15,7 +15,7 @@ import { AuthHeaders } from './lifecycle/auth'; * @return authentication headers {@link AuthHeaders} for - an incoming request. * @public * */ -export type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; +export type GetAuthHeaders = (request: KibanaRequest) => AuthHeaders | undefined; /** @internal */ export class AuthHeadersStorage { diff --git a/src/core/server/http/auth_state_storage.ts b/src/core/server/http/auth_state_storage.ts index e29742bccbaae..2dd49f8fb99cc 100644 --- a/src/core/server/http/auth_state_storage.ts +++ b/src/core/server/http/auth_state_storage.ts @@ -33,7 +33,7 @@ export enum AuthStatus { * @public */ export type GetAuthState = ( - request: KibanaRequest | LegacyRequest + request: KibanaRequest ) => { status: AuthStatus; state: T }; /** @@ -41,7 +41,7 @@ export type GetAuthState = ( * @param request {@link KibanaRequest} - an incoming request. * @public */ -export type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolean; +export type IsAuthenticated = (request: KibanaRequest) => boolean; /** @internal */ export class AuthStateStorage { diff --git a/src/core/server/http/base_path_service.test.ts b/src/core/server/http/base_path_service.test.ts index 751f4bc820e82..2d26000b4672b 100644 --- a/src/core/server/http/base_path_service.test.ts +++ b/src/core/server/http/base_path_service.test.ts @@ -7,7 +7,6 @@ */ import { BasePath } from './base_path_service'; -import { KibanaRequest } from './router'; import { httpServerMock } from './http_server.mocks'; describe('BasePath', () => { @@ -36,32 +35,16 @@ describe('BasePath', () => { }); describe('#get()', () => { - it('returns base path associated with an incoming Legacy.Request request', () => { - const request = httpServerMock.createRawRequest(); - - const basePath = new BasePath(); - basePath.set(request, '/baz/'); - expect(basePath.get(request)).toBe('/baz/'); - }); - it('returns base path associated with an incoming KibanaRequest', () => { - const request = httpServerMock.createRawRequest(); - const basePath = new BasePath(); - - basePath.set(KibanaRequest.from(request, undefined), '/baz/'); - expect(basePath.get(KibanaRequest.from(request, undefined))).toBe('/baz/'); - }); - - it('operates with both Legacy.Request/KibanaRequest requests', () => { - const request = httpServerMock.createRawRequest(); + const request = httpServerMock.createKibanaRequest(); const basePath = new BasePath(); basePath.set(request, '/baz/'); - expect(basePath.get(KibanaRequest.from(request, undefined))).toBe('/baz/'); + expect(basePath.get(request)).toBe('/baz/'); }); it('is based on server base path', () => { - const request = httpServerMock.createRawRequest(); + const request = httpServerMock.createKibanaRequest(); const basePath = new BasePath('/foo/bar'); basePath.set(request, '/baz/'); @@ -71,7 +54,7 @@ describe('BasePath', () => { describe('#set()', () => { it('#set() cannot be set twice for one request', () => { - const request = httpServerMock.createRawRequest(); + const request = httpServerMock.createKibanaRequest(); const basePath = new BasePath('/foo/bar'); const setPath = () => basePath.set(request, 'baz/'); diff --git a/src/core/server/http/base_path_service.ts b/src/core/server/http/base_path_service.ts index fbcf8ab59e45b..7de943a479810 100644 --- a/src/core/server/http/base_path_service.ts +++ b/src/core/server/http/base_path_service.ts @@ -42,7 +42,7 @@ export class BasePath { /** * returns `basePath` value, specific for an incoming request. */ - public get = (request: KibanaRequest | LegacyRequest) => { + public get = (request: KibanaRequest) => { const requestScopePath = this.basePathCache.get(ensureRawRequest(request)) || ''; return `${this.serverBasePath}${requestScopePath}`; }; @@ -52,7 +52,7 @@ export class BasePath { * * @privateRemarks should work only for KibanaRequest as soon as spaces migrate to NP */ - public set = (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => { + public set = (request: KibanaRequest, requestSpecificBasePath: string) => { const rawRequest = ensureRawRequest(request); if (this.basePathCache.has(rawRequest)) { diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index b09b200620fbf..22d747ff577ae 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -14,7 +14,6 @@ import { BehaviorSubject } from 'rxjs'; import { CoreContext } from '../core_context'; import { HttpService } from './http_service'; -import { KibanaRequest } from './router'; import { Env } from '../config'; import { contextServiceMock } from '../context/context_service.mock'; @@ -323,7 +322,7 @@ describe.skip('Cookie based SessionStorage', () => { }, }; - const mockRequest = httpServerMock.createRawRequest(); + const mockRequest = httpServerMock.createKibanaRequest(); const factory = await createCookieSessionStorageFactory( logger.get(), @@ -334,7 +333,7 @@ describe.skip('Cookie based SessionStorage', () => { expect(mockServer.register).toBeCalledTimes(1); expect(mockServer.auth.strategy).toBeCalledTimes(1); - const session = await factory.asScoped(KibanaRequest.from(mockRequest)).get(); + const session = await factory.asScoped(mockRequest).get(); expect(session).toBe(null); expect(mockServer.auth.test).toBeCalledTimes(1); @@ -354,7 +353,7 @@ describe.skip('Cookie based SessionStorage', () => { }, }; - const mockRequest = httpServerMock.createRawRequest(); + const mockRequest = httpServerMock.createKibanaRequest(); const factory = await createCookieSessionStorageFactory( logger.get(), @@ -365,7 +364,7 @@ describe.skip('Cookie based SessionStorage', () => { expect(mockServer.register).toBeCalledTimes(1); expect(mockServer.auth.strategy).toBeCalledTimes(1); - const session = await factory.asScoped(KibanaRequest.from(mockRequest)).get(); + const session = await factory.asScoped(mockRequest).get(); expect(session).toBe('foo'); expect(mockServer.auth.test).toBeCalledTimes(1); @@ -383,7 +382,7 @@ describe.skip('Cookie based SessionStorage', () => { }, }; - const mockRequest = httpServerMock.createRawRequest(); + const mockRequest = httpServerMock.createKibanaRequest(); const factory = await createCookieSessionStorageFactory( logger.get(), @@ -394,7 +393,7 @@ describe.skip('Cookie based SessionStorage', () => { expect(mockServer.register).toBeCalledTimes(1); expect(mockServer.auth.strategy).toBeCalledTimes(1); - const session = await factory.asScoped(KibanaRequest.from(mockRequest)).get(); + const session = await factory.asScoped(mockRequest).get(); expect(session).toBe(null); expect(loggingSystemMock.collect(logger).debug).toEqual([['Error: Invalid cookie.']]); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index cad5a50dbc505..a56071ed1d980 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -30,7 +30,6 @@ export type { KibanaRequestRouteOptions, IKibanaResponse, KnownHeaders, - LegacyRequest, LifecycleResponseFactory, RedirectResponseOptions, RequestHandler, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 19f7daff13ccc..1c3a0850d3b79 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -172,7 +172,6 @@ export type { IKibanaResponse, LifecycleResponseFactory, KnownHeaders, - LegacyRequest, OnPreAuthHandler, OnPreAuthToolkit, OnPreRoutingHandler, @@ -423,8 +422,6 @@ export type { CoreUsageDataStart } from './core_usage_data'; * all the registered types. * - {@link IScopedClusterClient | elasticsearch.client} - Elasticsearch * data client which uses the credentials of the incoming request - * - {@link LegacyScopedClusterClient | elasticsearch.legacy.client} - The legacy Elasticsearch - * data client which uses the credentials of the incoming request * - {@link IUiSettingsClient | uiSettings.client} - uiSettings client * which uses the credentials of the incoming request * diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 495a38a1af5bf..5ea65cd8d0c73 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -1,56 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RenderingService preboot() render() renders "core" from legacy request 1`] = ` -Object { - "anonymousStatusPage": false, - "basePath": "/mock-server-basepath", - "branch": Any, - "buildNumber": Any, - "csp": Object { - "warnLegacyBrowsers": true, - }, - "env": Object { - "mode": Object { - "dev": Any, - "name": Any, - "prod": Any, - }, - "packageInfo": Object { - "branch": Any, - "buildNum": Any, - "buildSha": Any, - "dist": Any, - "version": Any, - }, - }, - "externalUrl": Object { - "policy": Array [ - Object { - "allow": true, - }, - ], - }, - "i18n": Object { - "translationsUrl": "/mock-server-basepath/translations/en.json", - }, - "legacyMetadata": Object { - "uiSettings": Object { - "defaults": Object { - "registered": Object { - "name": "title", - }, - }, - "user": Object {}, - }, - }, - "publicBaseUrl": "http://myhost.com/mock-server-basepath", - "serverBasePath": "/mock-server-basepath", - "uiPlugins": Array [], - "vars": Object {}, - "version": Any, -} -`; - exports[`RenderingService preboot() render() renders "core" page 1`] = ` Object { "anonymousStatusPage": false, @@ -259,57 +208,6 @@ Object { } `; -exports[`RenderingService setup() render() renders "core" from legacy request 1`] = ` -Object { - "anonymousStatusPage": false, - "basePath": "/mock-server-basepath", - "branch": Any, - "buildNumber": Any, - "csp": Object { - "warnLegacyBrowsers": true, - }, - "env": Object { - "mode": Object { - "dev": Any, - "name": Any, - "prod": Any, - }, - "packageInfo": Object { - "branch": Any, - "buildNum": Any, - "buildSha": Any, - "dist": Any, - "version": Any, - }, - }, - "externalUrl": Object { - "policy": Array [ - Object { - "allow": true, - }, - ], - }, - "i18n": Object { - "translationsUrl": "/mock-server-basepath/translations/en.json", - }, - "legacyMetadata": Object { - "uiSettings": Object { - "defaults": Object { - "registered": Object { - "name": "title", - }, - }, - "user": Object {}, - }, - }, - "publicBaseUrl": "http://myhost.com/mock-server-basepath", - "serverBasePath": "/mock-server-basepath", - "uiPlugins": Array [], - "vars": Object {}, - "version": Any, -} -`; - exports[`RenderingService setup() render() renders "core" page 1`] = ` Object { "anonymousStatusPage": false, diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts index de7d21add6c6c..f75d405fe8bf9 100644 --- a/src/core/server/rendering/rendering_service.test.ts +++ b/src/core/server/rendering/rendering_service.test.ts @@ -46,7 +46,7 @@ const INJECTED_METADATA = { }, }; -const { createKibanaRequest, createRawRequest } = httpServerMock; +const { createKibanaRequest } = httpServerMock; function renderTestCases( getRender: () => Promise< @@ -107,15 +107,6 @@ function renderTestCases( expect(data).toMatchSnapshot(INJECTED_METADATA); }); - it('renders "core" from legacy request', async () => { - const [render] = await getRender(); - const content = await render(createRawRequest(), uiSettings); - const dom = load(content); - const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); - - expect(data).toMatchSnapshot(INJECTED_METADATA); - }); - it('calls `getStylesheetPaths` with the correct parameters', async () => { getSettingValueMock.mockImplementation((settingName: string) => { if (settingName === 'theme:darkMode') { diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 2d95822d92219..f8b99686ff557 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -24,7 +24,7 @@ import { } from './types'; import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap'; import { getSettingValue, getStylesheetPaths } from './render_utils'; -import { KibanaRequest, LegacyRequest } from '../http'; +import { KibanaRequest } from '../http'; import { IUiSettingsClient } from '../ui_settings'; type RenderOptions = (RenderingPrebootDeps & { status?: never }) | RenderingSetupDeps; @@ -76,7 +76,7 @@ export class RenderingService { private async render( { http, uiPlugins, status }: RenderOptions, - request: KibanaRequest | LegacyRequest, + request: KibanaRequest, uiSettings: IUiSettingsClient, { includeUserSettings = true, vars }: IRenderOptions = {} ) { diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 8dec4bc69d9ea..8089878ccefd0 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -10,12 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EnvironmentMode, PackageInfo } from '../config'; import { ICspConfig } from '../csp'; -import { - InternalHttpServicePreboot, - InternalHttpServiceSetup, - KibanaRequest, - LegacyRequest, -} from '../http'; +import { InternalHttpServicePreboot, InternalHttpServiceSetup, KibanaRequest } from '../http'; import { UiPlugins, DiscoveredPlugin } from '../plugins'; import { IUiSettingsClient, UserProvidedValues } from '../ui_settings'; import type { InternalStatusServiceSetup } from '../status'; @@ -103,8 +98,8 @@ export interface InternalRenderingServiceSetup { * const html = await rendering.render(request, uiSettings); * ``` */ - render( - request: R, + render( + request: KibanaRequest, uiSettings: IUiSettingsClient, options?: IRenderOptions ): Promise; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index cbe5f4e0cf1b6..47455e0c14316 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -159,12 +159,12 @@ export interface AuthToolkit { export class BasePath { // @internal constructor(serverBasePath?: string, publicBaseUrl?: string); - get: (request: KibanaRequest | LegacyRequest) => string; + get: (request: KibanaRequest) => string; prepend: (path: string) => string; readonly publicBaseUrl?: string; remove: (path: string) => string; readonly serverBasePath: string; - set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; + set: (request: KibanaRequest, requestSpecificBasePath: string) => void; } // Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts @@ -896,10 +896,10 @@ export interface FakeRequest { } // @public -export type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; +export type GetAuthHeaders = (request: KibanaRequest) => AuthHeaders | undefined; // @public -export type GetAuthState = (request: KibanaRequest | LegacyRequest) => { +export type GetAuthState = (request: KibanaRequest) => { status: AuthStatus; state: T; }; @@ -1137,7 +1137,7 @@ export interface IRouter boolean; +export type IsAuthenticated = (request: KibanaRequest) => boolean; // @public (undocumented) export type ISavedObjectsExporter = PublicMethodsOf; @@ -1263,10 +1263,6 @@ export const kibanaResponseFactory: { // @public export type KnownHeaders = KnownKeys; -// @public @deprecated (undocumented) -export interface LegacyRequest extends Request { -} - // Warning: (ae-forgotten-export) The symbol "lifecycleResponseFactory" needs to be exported by the entry point index.d.ts // // @public @@ -1607,8 +1603,6 @@ export interface RegisterDeprecationsConfig { // @public export type RequestHandler

= (context: Context, request: KibanaRequest, response: ResponseFactory) => IKibanaResponse | Promise>; -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "LegacyScopedClusterClient" -// // @public export interface RequestHandlerContext { // (undocumented) @@ -2719,7 +2713,7 @@ export class SavedObjectTypeRegistry { export type SavedObjectUnsanitizedDoc = SavedObjectDoc & Partial; // @public -export type ScopeableRequest = KibanaRequest | LegacyRequest | FakeRequest; +export type ScopeableRequest = KibanaRequest | FakeRequest; // @public (undocumented) export interface SearchResponse { diff --git a/src/dev/build/tasks/create_empty_dirs_and_files_task.ts b/src/dev/build/tasks/create_empty_dirs_and_files_task.ts index 6ef878cbab554..06b402c580151 100644 --- a/src/dev/build/tasks/create_empty_dirs_and_files_task.ts +++ b/src/dev/build/tasks/create_empty_dirs_and_files_task.ts @@ -12,6 +12,9 @@ export const CreateEmptyDirsAndFiles: Task = { description: 'Creating some empty directories and files to prevent file-permission issues', async run(config, log, build) { - await mkdirp(build.resolvePath('plugins')); + await Promise.all([ + mkdirp(build.resolvePath('plugins')), + mkdirp(build.resolvePath('data/optimize')), + ]); }, }; diff --git a/src/dev/ensure_all_tests_in_ci_group.ts b/src/dev/ensure_all_tests_in_ci_group.ts new file mode 100644 index 0000000000000..aeccefae05d2c --- /dev/null +++ b/src/dev/ensure_all_tests_in_ci_group.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs/promises'; + +import execa from 'execa'; +import { safeLoad } from 'js-yaml'; + +import { run, REPO_ROOT } from '@kbn/dev-utils'; +import { schema } from '@kbn/config-schema'; + +const RELATIVE_JOBS_YAML_PATH = '.ci/ci_groups.yml'; +const JOBS_YAML_PATH = Path.resolve(REPO_ROOT, RELATIVE_JOBS_YAML_PATH); +const SCHEMA = schema.object({ + root: schema.arrayOf(schema.string()), + xpack: schema.arrayOf(schema.string()), +}); + +export function runEnsureAllTestsInCiGroupsCli() { + run(async ({ log }) => { + const { root, xpack } = SCHEMA.validate(safeLoad(await Fs.readFile(JOBS_YAML_PATH, 'utf-8'))); + + log.info( + 'validating root tests directory contains all "root" ciGroups from', + RELATIVE_JOBS_YAML_PATH + ); + await execa(process.execPath, [ + 'scripts/functional_tests', + ...root.map((tag) => `--include-tag=${tag}`), + '--include-tag=runOutsideOfCiGroups', + '--assert-none-excluded', + ]); + + log.info( + 'validating x-pack/tests directory contains all "xpack" ciGroups from', + RELATIVE_JOBS_YAML_PATH + ); + await execa(process.execPath, [ + 'x-pack/scripts/functional_tests', + ...xpack.map((tag) => `--include-tag=${tag}`), + '--assert-none-excluded', + ]); + + log.success('all tests are in a valid ciGroup'); + }); +} diff --git a/src/dev/run_ensure_all_tests_in_ci_group.js b/src/dev/run_ensure_all_tests_in_ci_group.js deleted file mode 100644 index b5e4798d8d800..0000000000000 --- a/src/dev/run_ensure_all_tests_in_ci_group.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { readFileSync } from 'fs'; -import { resolve } from 'path'; - -import execa from 'execa'; -import { safeLoad } from 'js-yaml'; - -import { run } from '@kbn/dev-utils'; - -const JOBS_YAML = readFileSync(resolve(__dirname, '../../.ci/jobs.yml'), 'utf8'); -const TEST_TAGS = safeLoad(JOBS_YAML) - .JOB.filter((id) => id.startsWith('kibana-ciGroup')) - .map((id) => id.replace(/^kibana-/, '')); - -run(async ({ log }) => { - try { - const result = await execa(process.execPath, [ - 'scripts/functional_test_runner', - ...TEST_TAGS.map((tag) => `--include-tag=${tag}`), - '--config', - 'test/functional/config.js', - '--test-stats', - ]); - const stats = JSON.parse(result.stderr); - - if (stats.excludedTests.length > 0) { - log.error(` - ${stats.excludedTests.length} tests are excluded by the ciGroup tags, make sure that - all test suites have a "ciGroup{X}" tag and that "tasks/functional_test_groups.js" - knows about the tag that you are using. - - tags: ${JSON.stringify({ include: TEST_TAGS })} - - - ${stats.excludedTests.join('\n - ')} - `); - process.exitCode = 1; - return; - } - } catch (error) { - log.error(error.stack); - process.exitCode = 1; - } -}); diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx index 1352081eaa30b..f29ab120013c6 100644 --- a/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/authorization/components/authorization_provider.tsx @@ -9,7 +9,7 @@ import { HttpSetup } from 'kibana/public'; import React, { createContext, useContext } from 'react'; -import { useRequest } from '../../../public'; +import { useRequest } from '../../../public/request'; import { Privileges, Error as CustomError } from '../types'; diff --git a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx index cae3210857543..6299b473f68df 100644 --- a/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx +++ b/src/plugins/es_ui_shared/public/components/code_editor/code_editor.tsx @@ -46,7 +46,7 @@ export interface EuiCodeEditorProps extends SupportedAriaAttributes, Omit { static defaultProps = { - setOptions: {}, + setOptions: { + showLineNumbers: false, + tabSize: 2, + }, }; state: EuiCodeEditorState = { diff --git a/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts b/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts index 4e1506f69990c..bc0b172dfe234 100644 --- a/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts +++ b/src/plugins/interactive_setup/common/elasticsearch_connection_status.ts @@ -10,11 +10,6 @@ * Describes current status of the Elasticsearch connection. */ export enum ElasticsearchConnectionStatus { - /** - * Indicates that Kibana hasn't figured out yet if existing Elasticsearch connection configuration is valid. - */ - Unknown = 'unknown', - /** * Indicates that current Elasticsearch connection configuration valid and sufficient. */ diff --git a/src/plugins/interactive_setup/server/config.test.ts b/src/plugins/interactive_setup/server/config.test.ts new file mode 100644 index 0000000000000..b8ae673ad28f9 --- /dev/null +++ b/src/plugins/interactive_setup/server/config.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ConfigSchema } from './config'; + +describe('config schema', () => { + it('generates proper defaults', () => { + expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` + Object { + "connectionCheck": Object { + "interval": "PT5S", + }, + "enabled": false, + } + `); + }); + + describe('#connectionCheck', () => { + it('should properly set required connection check interval', () => { + expect(ConfigSchema.validate({ connectionCheck: { interval: '1s' } })).toMatchInlineSnapshot(` + Object { + "connectionCheck": Object { + "interval": "PT1S", + }, + "enabled": false, + } + `); + }); + + it('should throw error if interactiveSetup.connectionCheck.interval is less than 1 second', () => { + expect(() => + ConfigSchema.validate({ connectionCheck: { interval: 100 } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[connectionCheck.interval]: the value must be greater or equal to 1 second."` + ); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/config.ts b/src/plugins/interactive_setup/server/config.ts index b16c51bcbda09..9986f16e9ce93 100644 --- a/src/plugins/interactive_setup/server/config.ts +++ b/src/plugins/interactive_setup/server/config.ts @@ -13,4 +13,14 @@ export type ConfigType = TypeOf; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), + connectionCheck: schema.object({ + interval: schema.duration({ + defaultValue: '5s', + validate(value) { + if (value.asSeconds() < 1) { + return 'the value must be greater or equal to 1 second.'; + } + }, + }), + }), }); diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts b/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts new file mode 100644 index 0000000000000..8bc7e4307e76f --- /dev/null +++ b/src/plugins/interactive_setup/server/elasticsearch_service.mock.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; + +import { ElasticsearchConnectionStatus } from '../common'; + +export const elasticsearchServiceMock = { + createSetup: () => ({ + connectionStatus$: new BehaviorSubject( + ElasticsearchConnectionStatus.Configured + ), + enroll: jest.fn(), + }), +}; diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.test.ts b/src/plugins/interactive_setup/server/elasticsearch_service.test.ts new file mode 100644 index 0000000000000..b8eb7293fd678 --- /dev/null +++ b/src/plugins/interactive_setup/server/elasticsearch_service.test.ts @@ -0,0 +1,497 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors } from '@elastic/elasticsearch'; + +import { nextTick } from '@kbn/test/jest'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; + +import { ElasticsearchConnectionStatus } from '../common'; +import { ConfigSchema } from './config'; +import type { ElasticsearchServiceSetup } from './elasticsearch_service'; +import { ElasticsearchService } from './elasticsearch_service'; +import { interactiveSetupMock } from './mocks'; + +describe('ElasticsearchService', () => { + let service: ElasticsearchService; + let mockElasticsearchPreboot: ReturnType; + beforeEach(() => { + service = new ElasticsearchService(loggingSystemMock.createLogger()); + mockElasticsearchPreboot = elasticsearchServiceMock.createPreboot(); + }); + + describe('#setup()', () => { + let mockConnectionStatusClient: ReturnType< + typeof elasticsearchServiceMock.createCustomClusterClient + >; + let mockEnrollClient: ReturnType; + let mockAuthenticateClient: ReturnType< + typeof elasticsearchServiceMock.createCustomClusterClient + >; + let setupContract: ElasticsearchServiceSetup; + beforeEach(() => { + mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient(); + mockEnrollClient = elasticsearchServiceMock.createCustomClusterClient(); + mockAuthenticateClient = elasticsearchServiceMock.createCustomClusterClient(); + mockElasticsearchPreboot.createClient.mockImplementation((type) => { + switch (type) { + case 'enroll': + return mockEnrollClient; + case 'authenticate': + return mockAuthenticateClient; + default: + return mockConnectionStatusClient; + } + }); + + setupContract = service.setup({ + elasticsearch: mockElasticsearchPreboot, + connectionCheckInterval: ConfigSchema.validate({}).connectionCheck.interval, + }); + }); + + describe('#connectionStatus$', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('does not repeat ping request if have multiple subscriptions', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + const mockHandler1 = jest.fn(); + const mockHandler2 = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler1); + setupContract.connectionStatus$.subscribe(mockHandler2); + + jest.advanceTimersByTime(0); + await nextTick(); + + // Late subscription. + const mockHandler3 = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler3); + + jest.advanceTimersByTime(100); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler1).toHaveBeenCalledTimes(1); + expect(mockHandler1).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured); + expect(mockHandler2).toHaveBeenCalledTimes(1); + expect(mockHandler2).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured); + expect(mockHandler3).toHaveBeenCalledTimes(1); + expect(mockHandler3).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured); + }); + + it('does not report the same status twice', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + const mockHandler = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler); + + jest.advanceTimersByTime(0); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured); + + mockHandler.mockClear(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(2); + expect(mockHandler).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(3); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('stops status checks as soon as connection is known to be configured', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + const mockHandler = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler); + + jest.advanceTimersByTime(0); + await nextTick(); + + // Initial ping (connection error). + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured); + + // Repeated ping (Unauthorized error). + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} }) + ) + ); + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(2); + expect(mockHandler).toHaveBeenCalledTimes(2); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured); + + mockHandler.mockClear(); + mockConnectionStatusClient.asInternalUser.ping.mockClear(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('checks connection status only once if connection is known to be configured right from start', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockResolvedValue( + interactiveSetupMock.createApiResponse({ body: true }) + ); + + const mockHandler = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler); + + jest.advanceTimersByTime(0); + await nextTick(); + + // Initial ping (connection error). + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured); + + mockHandler.mockClear(); + mockConnectionStatusClient.asInternalUser.ping.mockClear(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + + const mockHandler2 = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler2); + + // Source observable is complete, and handler should be called immediately. + expect(mockHandler2).toHaveBeenCalledTimes(1); + expect(mockHandler2).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured); + + mockHandler2.mockClear(); + + // No status check should be made after the first attempt. + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + expect(mockHandler2).not.toHaveBeenCalled(); + }); + + it('does not check connection status if there are no subscribers', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + const mockHandler = jest.fn(); + const mockSubscription = setupContract.connectionStatus$.subscribe(mockHandler); + + jest.advanceTimersByTime(0); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.NotConfigured); + + mockSubscription.unsubscribe(); + mockHandler.mockClear(); + mockConnectionStatusClient.asInternalUser.ping.mockClear(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('treats non-connection errors the same as successful response', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} }) + ) + ); + + const mockHandler = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler); + + jest.advanceTimersByTime(0); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured); + + mockHandler.mockClear(); + mockConnectionStatusClient.asInternalUser.ping.mockClear(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('treats product check error the same as successful response', async () => { + mockConnectionStatusClient.asInternalUser.ping.mockRejectedValue( + new errors.ProductNotSupportedError(interactiveSetupMock.createApiResponse({ body: {} })) + ); + + const mockHandler = jest.fn(); + setupContract.connectionStatus$.subscribe(mockHandler); + + jest.advanceTimersByTime(0); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledTimes(1); + expect(mockHandler).toHaveBeenCalledWith(ElasticsearchConnectionStatus.Configured); + + mockHandler.mockClear(); + mockConnectionStatusClient.asInternalUser.ping.mockClear(); + + jest.advanceTimersByTime(5000); + await nextTick(); + + expect(mockConnectionStatusClient.asInternalUser.ping).not.toHaveBeenCalled(); + expect(mockHandler).not.toHaveBeenCalled(); + }); + }); + + describe('#enroll()', () => { + it('fails if enroll call fails', async () => { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.transport.request.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: { message: 'oh no' } }) + ) + ); + mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect( + setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'] }) + ).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`); + + expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockEnrollClient.close).toHaveBeenCalledTimes(1); + expect(mockAuthenticateClient.asInternalUser.security.authenticate).not.toHaveBeenCalled(); + }); + + it('fails if none of the hosts are accessible', async () => { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.transport.request.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + mockEnrollClient.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect( + setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1', 'host2'] }) + ).rejects.toMatchInlineSnapshot(`[Error: Unable to connect to any of the provided hosts.]`); + + expect(mockEnrollClient.close).toHaveBeenCalledTimes(2); + expect(mockAuthenticateClient.asInternalUser.security.authenticate).not.toHaveBeenCalled(); + }); + + it('fails if authenticate call fails', async () => { + const mockEnrollScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockEnrollScopedClusterClient.asCurrentUser.transport.request.mockResolvedValue( + interactiveSetupMock.createApiResponse({ + statusCode: 200, + body: { token: { name: 'some-name', value: 'some-value' }, http_ca: 'some-ca' }, + }) + ); + mockEnrollClient.asScoped.mockReturnValue(mockEnrollScopedClusterClient); + + mockAuthenticateClient.asInternalUser.security.authenticate.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: { message: 'oh no' } }) + ) + ); + + await expect( + setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1'] }) + ).rejects.toMatchInlineSnapshot(`[ResponseError: {"message":"oh no"}]`); + + expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockEnrollClient.close).toHaveBeenCalledTimes(1); + expect(mockAuthenticateClient.asInternalUser.security.authenticate).toHaveBeenCalledTimes( + 1 + ); + expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1); + }); + + it('iterates through all provided hosts until find an accessible one', async () => { + mockElasticsearchPreboot.createClient.mockClear(); + + const mockHostOneEnrollScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockHostOneEnrollScopedClusterClient.asCurrentUser.transport.request.mockRejectedValue( + new errors.ConnectionError( + 'some-message', + interactiveSetupMock.createApiResponse({ body: {} }) + ) + ); + + const mockHostTwoEnrollScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockHostTwoEnrollScopedClusterClient.asCurrentUser.transport.request.mockResolvedValue( + interactiveSetupMock.createApiResponse({ + statusCode: 200, + body: { + token: { name: 'some-name', value: 'some-value' }, + http_ca: '\n\nsome weird-ca_with\n content\n\n', + }, + }) + ); + + mockEnrollClient.asScoped + .mockReturnValueOnce(mockHostOneEnrollScopedClusterClient) + .mockReturnValueOnce(mockHostTwoEnrollScopedClusterClient); + + mockAuthenticateClient.asInternalUser.security.authenticate.mockResolvedValue( + interactiveSetupMock.createApiResponse({ statusCode: 200, body: {} as any }) + ); + + const expectedCa = `-----BEGIN CERTIFICATE----- + + +some weird+ca/with + + content + + +-----END CERTIFICATE----- +`; + + await expect( + setupContract.enroll({ apiKey: 'apiKey', hosts: ['host1', 'host2'] }) + ).resolves.toEqual({ + ca: expectedCa, + host: 'host2', + serviceAccountToken: { + name: 'some-name', + value: 'some-value', + }, + }); + + // Check that we created clients with the right parameters + expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledTimes(3); + expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', { + hosts: ['host1'], + ssl: { verificationMode: 'none' }, + }); + expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('enroll', { + hosts: ['host2'], + ssl: { verificationMode: 'none' }, + }); + expect(mockElasticsearchPreboot.createClient).toHaveBeenCalledWith('authenticate', { + hosts: ['host2'], + serviceAccountToken: 'some-value', + ssl: { certificateAuthorities: [expectedCa] }, + }); + + // Check that we properly provided apiKeys to scoped clients. + expect(mockEnrollClient.asScoped).toHaveBeenCalledTimes(2); + expect(mockEnrollClient.asScoped).toHaveBeenNthCalledWith(1, { + headers: { authorization: 'ApiKey apiKey' }, + }); + expect(mockEnrollClient.asScoped).toHaveBeenNthCalledWith(2, { + headers: { authorization: 'ApiKey apiKey' }, + }); + + // Check that we properly called all required ES APIs. + expect( + mockHostOneEnrollScopedClusterClient.asCurrentUser.transport.request + ).toHaveBeenCalledTimes(1); + expect( + mockHostOneEnrollScopedClusterClient.asCurrentUser.transport.request + ).toHaveBeenCalledWith({ + method: 'GET', + path: '/_security/enroll/kibana', + }); + expect( + mockHostTwoEnrollScopedClusterClient.asCurrentUser.transport.request + ).toHaveBeenCalledTimes(1); + expect( + mockHostTwoEnrollScopedClusterClient.asCurrentUser.transport.request + ).toHaveBeenCalledWith({ + method: 'GET', + path: '/_security/enroll/kibana', + }); + expect(mockAuthenticateClient.asInternalUser.security.authenticate).toHaveBeenCalledTimes( + 1 + ); + + // Check that we properly closed all clients. + expect(mockEnrollClient.close).toHaveBeenCalledTimes(2); + expect(mockAuthenticateClient.close).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('#stop()', () => { + it('does not fail if called before `setup`', () => { + expect(() => service.stop()).not.toThrow(); + }); + + it('closes connection status check client', async () => { + const mockConnectionStatusClient = elasticsearchServiceMock.createCustomClusterClient(); + mockElasticsearchPreboot.createClient.mockImplementation((type) => { + switch (type) { + case 'ping': + return mockConnectionStatusClient; + default: + throw new Error(`Unexpected client type: ${type}`); + } + }); + + service.setup({ + elasticsearch: mockElasticsearchPreboot, + connectionCheckInterval: ConfigSchema.validate({}).connectionCheck.interval, + }); + service.stop(); + + expect(mockConnectionStatusClient.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/elasticsearch_service.ts b/src/plugins/interactive_setup/server/elasticsearch_service.ts new file mode 100644 index 0000000000000..cad34e1a4d44a --- /dev/null +++ b/src/plugins/interactive_setup/server/elasticsearch_service.ts @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ApiResponse } from '@elastic/elasticsearch'; +import { errors } from '@elastic/elasticsearch'; +import type { Duration } from 'moment'; +import type { Observable } from 'rxjs'; +import { from, of, timer } from 'rxjs'; +import { + catchError, + distinctUntilChanged, + exhaustMap, + map, + shareReplay, + takeWhile, +} from 'rxjs/operators'; + +import type { + ElasticsearchClientConfig, + ElasticsearchServicePreboot, + ICustomClusterClient, + Logger, + ScopeableRequest, +} from 'src/core/server'; + +import { ElasticsearchConnectionStatus } from '../common'; +import { getDetailedErrorMessage } from './errors'; + +interface EnrollParameters { + apiKey: string; + hosts: string[]; + // TODO: Integrate fingerprint check as soon core supports this new option: + // https://github.com/elastic/kibana/pull/108514 + caFingerprint?: string; +} + +export interface ElasticsearchServiceSetupDeps { + /** + * Core Elasticsearch service preboot contract; + */ + elasticsearch: ElasticsearchServicePreboot; + + /** + * Interval for the Elasticsearch connection check (whether it's configured or not). + */ + connectionCheckInterval: Duration; +} + +export interface ElasticsearchServiceSetup { + /** + * Observable that yields the last result of the Elasticsearch connection status check. + */ + connectionStatus$: Observable; + + /** + * Iterates through provided {@param hosts} one by one trying to call Kibana enrollment API using + * the specified {@param apiKey}. + * @param apiKey The ApiKey to use to authenticate Kibana enrollment request. + * @param hosts The list of Elasticsearch node addresses to enroll with. The addresses are supposed + * to point to exactly same Elasticsearch node, potentially available via different network interfaces. + */ + enroll: (params: EnrollParameters) => Promise; +} + +/** + * Result of the enrollment request. + */ +export interface EnrollResult { + /** + * Host address of the Elasticsearch node that successfully processed enrollment request. + */ + host: string; + /** + * PEM CA certificate for the Elasticsearch HTTP certificates. + */ + ca: string; + /** + * Service account token for the "elastic/kibana" service account. + */ + serviceAccountToken: { name: string; value: string }; +} + +export class ElasticsearchService { + /** + * Elasticsearch client used to check Elasticsearch connection status. + */ + private connectionStatusClient?: ICustomClusterClient; + constructor(private readonly logger: Logger) {} + + public setup({ + elasticsearch, + connectionCheckInterval, + }: ElasticsearchServiceSetupDeps): ElasticsearchServiceSetup { + const connectionStatusClient = (this.connectionStatusClient = elasticsearch.createClient( + 'ping' + )); + + return { + connectionStatus$: timer(0, connectionCheckInterval.asMilliseconds()).pipe( + exhaustMap(() => { + return from(connectionStatusClient.asInternalUser.ping()).pipe( + map(() => ElasticsearchConnectionStatus.Configured), + catchError((pingError) => + of( + pingError instanceof errors.ConnectionError + ? ElasticsearchConnectionStatus.NotConfigured + : ElasticsearchConnectionStatus.Configured + ) + ) + ); + }), + takeWhile( + (status) => status !== ElasticsearchConnectionStatus.Configured, + /* inclusive */ true + ), + distinctUntilChanged(), + shareReplay({ refCount: true, bufferSize: 1 }) + ), + enroll: this.enroll.bind(this, elasticsearch), + }; + } + + public stop() { + if (this.connectionStatusClient) { + this.connectionStatusClient.close().catch((err) => { + this.logger.debug(`Failed to stop Elasticsearch service: ${getDetailedErrorMessage(err)}`); + }); + this.connectionStatusClient = undefined; + } + } + + /** + * Iterates through provided {@param hosts} one by one trying to call Kibana enrollment API using + * the specified {@param apiKey}. + * @param elasticsearch Core Elasticsearch service preboot contract. + * @param apiKey The ApiKey to use to authenticate Kibana enrollment request. + * @param hosts The list of Elasticsearch node addresses to enroll with. The addresses are supposed + * to point to exactly same Elasticsearch node, potentially available via different network interfaces. + */ + private async enroll( + elasticsearch: ElasticsearchServicePreboot, + { apiKey, hosts }: EnrollParameters + ): Promise { + const scopeableRequest: ScopeableRequest = { headers: { authorization: `ApiKey ${apiKey}` } }; + const elasticsearchConfig: Partial = { + ssl: { verificationMode: 'none' }, + }; + + // We should iterate through all provided hosts until we find an accessible one. + for (const host of hosts) { + this.logger.debug(`Trying to enroll with "${host}" host`); + const enrollClient = elasticsearch.createClient('enroll', { + ...elasticsearchConfig, + hosts: [host], + }); + + let enrollmentResponse; + try { + enrollmentResponse = (await enrollClient + .asScoped(scopeableRequest) + .asCurrentUser.transport.request({ + method: 'GET', + path: '/_security/enroll/kibana', + })) as ApiResponse<{ token: { name: string; value: string }; http_ca: string }>; + } catch (err) { + // We expect that all hosts belong to exactly same node and any non-connection error for one host would mean + // that enrollment will fail for any other host and we should bail out. + if (err instanceof errors.ConnectionError || err instanceof errors.TimeoutError) { + this.logger.error( + `Unable to connect to "${host}" host, will proceed to the next host if available: ${getDetailedErrorMessage( + err + )}` + ); + continue; + } + + this.logger.error(`Failed to enroll with "${host}" host: ${getDetailedErrorMessage(err)}`); + throw err; + } finally { + await enrollClient.close(); + } + + this.logger.debug( + `Successfully enrolled with "${host}" host, token name: ${enrollmentResponse.body.token.name}, CA certificate: ${enrollmentResponse.body.http_ca}` + ); + + const enrollResult = { + host, + ca: ElasticsearchService.createPemCertificate(enrollmentResponse.body.http_ca), + serviceAccountToken: enrollmentResponse.body.token, + }; + + // Now try to use retrieved password and CA certificate to authenticate to this host. + const authenticateClient = elasticsearch.createClient('authenticate', { + hosts: [host], + serviceAccountToken: enrollResult.serviceAccountToken.value, + ssl: { certificateAuthorities: [enrollResult.ca] }, + }); + + this.logger.debug( + `Verifying if "${enrollmentResponse.body.token.name}" token can authenticate to "${host}" host.` + ); + + try { + await authenticateClient.asInternalUser.security.authenticate(); + this.logger.debug( + `Successfully authenticated "${enrollmentResponse.body.token.name}" token to "${host}" host.` + ); + } catch (err) { + this.logger.error( + `Failed to authenticate "${ + enrollmentResponse.body.token.name + }" token to "${host}" host: ${getDetailedErrorMessage(err)}.` + ); + throw err; + } finally { + await authenticateClient.close(); + } + + return enrollResult; + } + + throw new Error('Unable to connect to any of the provided hosts.'); + } + + private static createPemCertificate(derCaString: string) { + // Use `X509Certificate` class once we upgrade to Node v16. + return `-----BEGIN CERTIFICATE-----\n${derCaString + .replace(/_/g, '/') + .replace(/-/g, '+') + .replace(/([^\n]{1,65})/g, '$1\n') + .replace(/\n$/g, '')}\n-----END CERTIFICATE-----\n`; + } +} diff --git a/src/plugins/interactive_setup/server/errors.test.ts b/src/plugins/interactive_setup/server/errors.test.ts new file mode 100644 index 0000000000000..e9ef64fb0d3d7 --- /dev/null +++ b/src/plugins/interactive_setup/server/errors.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors as esErrors } from '@elastic/elasticsearch'; + +import * as errors from './errors'; +import { interactiveSetupMock } from './mocks'; + +describe('errors', () => { + describe('#getErrorStatusCode', () => { + it('extracts status code from Elasticsearch client response error', () => { + expect( + errors.getErrorStatusCode( + new esErrors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 400, body: {} }) + ) + ) + ).toBe(400); + expect( + errors.getErrorStatusCode( + new esErrors.ResponseError( + interactiveSetupMock.createApiResponse({ statusCode: 401, body: {} }) + ) + ) + ).toBe(401); + }); + + it('extracts status code from `status` property', () => { + expect(errors.getErrorStatusCode({ statusText: 'Bad Request', status: 400 })).toBe(400); + expect(errors.getErrorStatusCode({ statusText: 'Unauthorized', status: 401 })).toBe(401); + }); + }); + + describe('#getDetailedErrorMessage', () => { + it('extracts body from Elasticsearch client response error', () => { + expect( + errors.getDetailedErrorMessage( + new esErrors.ResponseError( + interactiveSetupMock.createApiResponse({ + statusCode: 401, + body: { field1: 'value-1', field2: 'value-2' }, + }) + ) + ) + ).toBe(JSON.stringify({ field1: 'value-1', field2: 'value-2' })); + }); + + it('extracts `message` property', () => { + expect(errors.getDetailedErrorMessage(new Error('some-message'))).toBe('some-message'); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/errors.ts b/src/plugins/interactive_setup/server/errors.ts new file mode 100644 index 0000000000000..5f1d2388b3938 --- /dev/null +++ b/src/plugins/interactive_setup/server/errors.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors } from '@elastic/elasticsearch'; + +/** + * Extracts error code from Boom and Elasticsearch "native" errors. + * @param error Error instance to extract status code from. + */ +export function getErrorStatusCode(error: any): number { + if (error instanceof errors.ResponseError) { + return error.statusCode; + } + + return error.statusCode || error.status; +} + +/** + * Extracts detailed error message from Boom and Elasticsearch "native" errors. It's supposed to be + * only logged on the server side and never returned to the client as it may contain sensitive + * information. + * @param error Error instance to extract message from. + */ +export function getDetailedErrorMessage(error: any): string { + if (error instanceof errors.ResponseError) { + return JSON.stringify(error.body); + } + + return error.message; +} diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.mock.ts b/src/plugins/interactive_setup/server/kibana_config_writer.mock.ts new file mode 100644 index 0000000000000..d2c498e5fc077 --- /dev/null +++ b/src/plugins/interactive_setup/server/kibana_config_writer.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; + +import type { KibanaConfigWriter } from './kibana_config_writer'; + +export const kibanaConfigWriterMock = { + create: (): jest.Mocked> => ({ + isConfigWritable: jest.fn().mockResolvedValue(true), + writeConfig: jest.fn().mockResolvedValue(undefined), + }), +}; diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.test.ts b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts new file mode 100644 index 0000000000000..7ae98157ba156 --- /dev/null +++ b/src/plugins/interactive_setup/server/kibana_config_writer.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +jest.mock('fs/promises'); +import { constants } from 'fs'; + +import { loggingSystemMock } from 'src/core/server/mocks'; + +import { KibanaConfigWriter } from './kibana_config_writer'; + +describe('KibanaConfigWriter', () => { + let mockFsAccess: jest.Mock; + let mockWriteFile: jest.Mock; + let mockAppendFile: jest.Mock; + let kibanaConfigWriter: KibanaConfigWriter; + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(1234); + + const fsMocks = jest.requireMock('fs/promises'); + mockFsAccess = fsMocks.access; + mockWriteFile = fsMocks.writeFile; + mockAppendFile = fsMocks.appendFile; + + kibanaConfigWriter = new KibanaConfigWriter( + '/some/path/kibana.yml', + loggingSystemMock.createLogger() + ); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('#isConfigWritable()', () => { + it('returns `false` if config directory is not writable even if kibana yml is writable', async () => { + mockFsAccess.mockImplementation((path, modifier) => + path === '/some/path' && modifier === constants.W_OK ? Promise.reject() : Promise.resolve() + ); + + await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false); + }); + + it('returns `false` if kibana yml is NOT writable if even config directory is writable', async () => { + mockFsAccess.mockImplementation((path, modifier) => + path === '/some/path/kibana.yml' && modifier === constants.W_OK + ? Promise.reject() + : Promise.resolve() + ); + + await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(false); + }); + + it('returns `true` if both kibana yml and config directory are writable', async () => { + mockFsAccess.mockResolvedValue(undefined); + + await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(true); + }); + + it('returns `true` even if kibana yml does not exist when config directory is writable', async () => { + mockFsAccess.mockImplementation((path) => + path === '/some/path/kibana.yml' ? Promise.reject() : Promise.resolve() + ); + + await expect(kibanaConfigWriter.isConfigWritable()).resolves.toBe(true); + }); + }); + + describe('#writeConfig()', () => { + it('throws if cannot write CA file', async () => { + mockWriteFile.mockRejectedValue(new Error('Oh no!')); + + await expect( + kibanaConfigWriter.writeConfig({ + ca: 'ca-content', + host: '', + serviceAccountToken: { name: '', value: '' }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`); + + expect(mockWriteFile).toHaveBeenCalledTimes(1); + expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content'); + expect(mockAppendFile).not.toHaveBeenCalled(); + }); + + it('throws if cannot append config to yaml file', async () => { + mockAppendFile.mockRejectedValue(new Error('Oh no!')); + + await expect( + kibanaConfigWriter.writeConfig({ + ca: 'ca-content', + host: 'some-host', + serviceAccountToken: { name: 'some-token', value: 'some-value' }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Oh no!]`); + + expect(mockWriteFile).toHaveBeenCalledTimes(1); + expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content'); + expect(mockAppendFile).toHaveBeenCalledTimes(1); + expect(mockAppendFile).toHaveBeenCalledWith( + '/some/path/kibana.yml', + ` + +# This section was automatically generated during setup (service account token name is "some-token"). +elasticsearch.hosts: [some-host] +elasticsearch.serviceAccountToken: some-value +elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] + +` + ); + }); + + it('can successfully write CA certificate and elasticsearch config to the disk', async () => { + await expect( + kibanaConfigWriter.writeConfig({ + ca: 'ca-content', + host: 'some-host', + serviceAccountToken: { name: 'some-token', value: 'some-value' }, + }) + ).resolves.toBeUndefined(); + + expect(mockWriteFile).toHaveBeenCalledTimes(1); + expect(mockWriteFile).toHaveBeenCalledWith('/some/path/ca_1234.crt', 'ca-content'); + expect(mockAppendFile).toHaveBeenCalledTimes(1); + expect(mockAppendFile).toHaveBeenCalledWith( + '/some/path/kibana.yml', + ` + +# This section was automatically generated during setup (service account token name is "some-token"). +elasticsearch.hosts: [some-host] +elasticsearch.serviceAccountToken: some-value +elasticsearch.ssl.certificateAuthorities: [/some/path/ca_1234.crt] + +` + ); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/kibana_config_writer.ts b/src/plugins/interactive_setup/server/kibana_config_writer.ts new file mode 100644 index 0000000000000..b3178d9a909bd --- /dev/null +++ b/src/plugins/interactive_setup/server/kibana_config_writer.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { constants } from 'fs'; +import fs from 'fs/promises'; +import yaml from 'js-yaml'; +import path from 'path'; + +import type { Logger } from 'src/core/server'; + +import { getDetailedErrorMessage } from './errors'; + +export interface WriteConfigParameters { + host: string; + ca: string; + serviceAccountToken: { name: string; value: string }; +} + +export class KibanaConfigWriter { + constructor(private readonly configPath: string, private readonly logger: Logger) {} + + /** + * Checks if we can write to the Kibana configuration file and configuration directory. + */ + public async isConfigWritable() { + try { + // We perform two separate checks here: + // 1. If we can write to config directory to add a new CA certificate file and potentially Kibana configuration + // file if it doesn't exist for some reason. + // 2. If we can write to the Kibana configuration file if it exists. + const canWriteToConfigDirectory = fs.access(path.dirname(this.configPath), constants.W_OK); + await Promise.all([ + canWriteToConfigDirectory, + fs.access(this.configPath, constants.F_OK).then( + () => fs.access(this.configPath, constants.W_OK), + () => canWriteToConfigDirectory + ), + ]); + return true; + } catch { + return false; + } + } + + /** + * Writes Elasticsearch configuration to the disk. + * @param params + */ + public async writeConfig(params: WriteConfigParameters) { + const caPath = path.join(path.dirname(this.configPath), `ca_${Date.now()}.crt`); + + this.logger.debug(`Writing CA certificate to ${caPath}.`); + try { + await fs.writeFile(caPath, params.ca); + this.logger.debug(`Successfully wrote CA certificate to ${caPath}.`); + } catch (err) { + this.logger.error( + `Failed to write CA certificate to ${caPath}: ${getDetailedErrorMessage(err)}.` + ); + throw err; + } + + this.logger.debug(`Writing Elasticsearch configuration to ${this.configPath}.`); + try { + await fs.appendFile( + this.configPath, + `\n\n# This section was automatically generated during setup (service account token name is "${ + params.serviceAccountToken.name + }").\n${yaml.safeDump( + { + 'elasticsearch.hosts': [params.host], + 'elasticsearch.serviceAccountToken': params.serviceAccountToken.value, + 'elasticsearch.ssl.certificateAuthorities': [caPath], + }, + { flowLevel: 1 } + )}\n` + ); + this.logger.debug(`Successfully wrote Elasticsearch configuration to ${this.configPath}.`); + } catch (err) { + this.logger.error( + `Failed to write Elasticsearch configuration to ${ + this.configPath + }: ${getDetailedErrorMessage(err)}.` + ); + throw err; + } + } +} diff --git a/src/plugins/interactive_setup/server/mocks.ts b/src/plugins/interactive_setup/server/mocks.ts new file mode 100644 index 0000000000000..75b28a502b6d4 --- /dev/null +++ b/src/plugins/interactive_setup/server/mocks.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ApiResponse } from '@elastic/elasticsearch'; + +function createApiResponseMock( + apiResponse: Pick, 'body'> & + Partial, 'body'>> +): ApiResponse { + return { + statusCode: null, + headers: null, + warnings: null, + meta: {} as any, + ...apiResponse, + }; +} + +export const interactiveSetupMock = { + createApiResponse: createApiResponseMock, +}; diff --git a/src/plugins/interactive_setup/server/plugin.ts b/src/plugins/interactive_setup/server/plugin.ts index 6b2a12bad76bc..06ece32ba9c4e 100644 --- a/src/plugins/interactive_setup/server/plugin.ts +++ b/src/plugins/interactive_setup/server/plugin.ts @@ -13,11 +13,18 @@ import type { CorePreboot, Logger, PluginInitializerContext, PrebootPlugin } fro import { ElasticsearchConnectionStatus } from '../common'; import type { ConfigSchema, ConfigType } from './config'; +import { ElasticsearchService } from './elasticsearch_service'; +import { KibanaConfigWriter } from './kibana_config_writer'; import { defineRoutes } from './routes'; export class UserSetupPlugin implements PrebootPlugin { readonly #logger: Logger; + #elasticsearchConnectionStatusSubscription?: Subscription; + readonly #elasticsearch = new ElasticsearchService( + this.initializerContext.logger.get('elasticsearch') + ); + #configSubscription?: Subscription; #config?: ConfigType; readonly #getConfig = () => { @@ -27,11 +34,6 @@ export class UserSetupPlugin implements PrebootPlugin { return this.#config; }; - #elasticsearchConnectionStatus = ElasticsearchConnectionStatus.Unknown; - readonly #getElasticsearchConnectionStatus = () => { - return this.#elasticsearchConnectionStatus; - }; - constructor(private readonly initializerContext: PluginInitializerContext) { this.#logger = this.initializerContext.logger.get(); } @@ -65,45 +67,48 @@ export class UserSetupPlugin implements PrebootPlugin { }) ); - // If preliminary check above indicates that user didn't alter default Elasticsearch connection - // details, it doesn't mean Elasticsearch connection isn't configured. There is a chance that they - // already disabled security features in Elasticsearch and everything should work by default. - // We should check if we can connect to Elasticsearch with default configuration to know if we - // need to activate interactive setup. This check can take some time, so we should register our - // routes to let interactive setup UI to handle user requests until the check is complete. - core.elasticsearch - .createClient('ping') - .asInternalUser.ping() - .then( - (pingResponse) => { - if (pingResponse.body) { - this.#logger.debug( - 'Kibana is already properly configured to connect to Elasticsearch. Interactive setup mode will not be activated.' - ); - this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.Configured; - completeSetup({ shouldReloadConfig: false }); - } else { - this.#logger.debug( - 'Kibana is not properly configured to connect to Elasticsearch. Interactive setup mode will be activated.' - ); - this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.NotConfigured; - } - }, - () => { - // TODO: we should probably react differently to different errors. 401 - credentials aren't correct, etc. - // Do we want to constantly ping ES if interactive mode UI isn't active? Just in case user runs Kibana and then - // configure Elasticsearch so that it can eventually connect to it without any configuration changes? - this.#elasticsearchConnectionStatus = ElasticsearchConnectionStatus.NotConfigured; + // If preliminary checks above indicate that user didn't alter default Elasticsearch connection + // details, it doesn't mean Elasticsearch connection isn't configured. There is a chance that + // user has already disabled security features in Elasticsearch and everything should work by + // default. We should check if we can connect to Elasticsearch with default configuration to + // know if we need to activate interactive setup. This check can take some time, so we should + // register our routes to let interactive setup UI to handle user requests until the check is + // complete. Moreover Elasticsearch may be just temporarily unavailable and we should poll its + // status until we can connect or use configures connection via interactive setup mode. + const elasticsearch = this.#elasticsearch.setup({ + elasticsearch: core.elasticsearch, + connectionCheckInterval: this.#getConfig().connectionCheck.interval, + }); + this.#elasticsearchConnectionStatusSubscription = elasticsearch.connectionStatus$.subscribe( + (status) => { + if (status === ElasticsearchConnectionStatus.Configured) { + this.#logger.debug( + 'Skipping interactive setup mode since Kibana is already properly configured to connect to Elasticsearch at http://localhost:9200.' + ); + completeSetup({ shouldReloadConfig: false }); + } else { + this.#logger.debug( + 'Starting interactive setup mode since Kibana cannot to connect to Elasticsearch at http://localhost:9200.' + ); } - ); + } + ); + + // If possible, try to use `*.dev.yml` config when Kibana is run in development mode. + const configPath = this.initializerContext.env.mode.dev + ? this.initializerContext.env.configs.find((config) => config.endsWith('.dev.yml')) ?? + this.initializerContext.env.configs[0] + : this.initializerContext.env.configs[0]; core.http.registerRoutes('', (router) => { defineRoutes({ router, basePath: core.http.basePath, logger: this.#logger.get('routes'), + preboot: { ...core.preboot, completeSetup }, + kibanaConfigWriter: new KibanaConfigWriter(configPath, this.#logger.get('kibana-config')), + elasticsearch, getConfig: this.#getConfig.bind(this), - getElasticsearchConnectionStatus: this.#getElasticsearchConnectionStatus.bind(this), }); }); } @@ -115,5 +120,12 @@ export class UserSetupPlugin implements PrebootPlugin { this.#configSubscription.unsubscribe(); this.#configSubscription = undefined; } + + if (this.#elasticsearchConnectionStatusSubscription) { + this.#elasticsearchConnectionStatusSubscription.unsubscribe(); + this.#elasticsearchConnectionStatusSubscription = undefined; + } + + this.#elasticsearch.stop(); } } diff --git a/src/plugins/interactive_setup/server/routes/enroll.test.ts b/src/plugins/interactive_setup/server/routes/enroll.test.ts new file mode 100644 index 0000000000000..4fc91e5252480 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/enroll.test.ts @@ -0,0 +1,305 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { errors } from '@elastic/elasticsearch'; + +import type { ObjectType } from '@kbn/config-schema'; +import type { IRouter, RequestHandler, RequestHandlerContext, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +import { ElasticsearchConnectionStatus } from '../../common'; +import { interactiveSetupMock } from '../mocks'; +import { defineEnrollRoutes } from './enroll'; +import { routeDefinitionParamsMock } from './index.mock'; + +describe('Enroll routes', () => { + let router: jest.Mocked; + let mockRouteParams: ReturnType; + let mockContext: RequestHandlerContext; + beforeEach(() => { + mockRouteParams = routeDefinitionParamsMock.create(); + router = mockRouteParams.router; + + mockContext = ({} as unknown) as RequestHandlerContext; + + defineEnrollRoutes(mockRouteParams); + }); + + describe('#enroll', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + + beforeEach(() => { + const [enrollRouteConfig, enrollRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/interactive_setup/enroll' + )!; + + routeConfig = enrollRouteConfig; + routeHandler = enrollRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + const bodySchema = (routeConfig.validate as any).body as ObjectType; + expect(() => bodySchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[hosts]: expected value of type [array] but got [undefined]"` + ); + + expect(() => bodySchema.validate({ hosts: [] })).toThrowErrorMatchingInlineSnapshot( + `"[hosts]: array size is [0], but cannot be smaller than [1]"` + ); + expect(() => + bodySchema.validate({ hosts: ['localhost:9200'] }) + ).toThrowErrorMatchingInlineSnapshot(`"[hosts.0]: expected URI with scheme [https]."`); + expect(() => + bodySchema.validate({ hosts: ['http://localhost:9200'] }) + ).toThrowErrorMatchingInlineSnapshot(`"[hosts.0]: expected URI with scheme [https]."`); + expect(() => + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200', 'http://localhost:9243'], + }) + ).toThrowErrorMatchingInlineSnapshot(`"[hosts.1]: expected URI with scheme [https]."`); + + expect(() => + bodySchema.validate({ hosts: ['https://localhost:9200'] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[apiKey]: expected value of type [string] but got [undefined]"` + ); + expect(() => + bodySchema.validate({ apiKey: '', hosts: ['https://localhost:9200'] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[apiKey]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodySchema.validate({ apiKey: 'some-key', hosts: ['https://localhost:9200'] }) + ).toThrowErrorMatchingInlineSnapshot( + `"[caFingerprint]: expected value of type [string] but got [undefined]"` + ); + expect(() => + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: '12345', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[caFingerprint]: value has length [5] but it must have a minimum length of [64]."` + ); + + expect( + bodySchema.validate( + bodySchema.validate({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: 'a'.repeat(64), + }) + ) + ).toEqual({ + apiKey: 'some-key', + hosts: ['https://localhost:9200'], + caFingerprint: 'a'.repeat(64), + }); + }); + + it('fails if setup is not on hold.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 400, + options: { body: 'Cannot process request outside of preboot stage.' }, + payload: 'Cannot process request outside of preboot stage.', + }); + + expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if Elasticsearch connection is already configured.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.Configured + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 400, + options: { + body: { + message: 'Elasticsearch connection is already configured.', + attributes: { type: 'elasticsearch_connection_configured' }, + }, + }, + payload: { + message: 'Elasticsearch connection is already configured.', + attributes: { type: 'elasticsearch_connection_configured' }, + }, + }); + + expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if Kibana config is not writable.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(false); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { + message: 'Kibana process does not have enough permissions to write to config file.', + attributes: { type: 'kibana_config_not_writable' }, + }, + statusCode: 500, + }, + payload: { + message: 'Kibana process does not have enough permissions to write to config file.', + attributes: { type: 'kibana_config_not_writable' }, + }, + }); + + expect(mockRouteParams.elasticsearch.enroll).not.toHaveBeenCalled(); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if enroll call fails.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true); + mockRouteParams.elasticsearch.enroll.mockRejectedValue( + new errors.ResponseError( + interactiveSetupMock.createApiResponse({ + statusCode: 401, + body: { message: 'some-secret-message' }, + }) + ) + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } }, + statusCode: 500, + }, + payload: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } }, + }); + + expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).not.toHaveBeenCalled(); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('fails if cannot write configuration to the disk.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true); + mockRouteParams.elasticsearch.enroll.mockResolvedValue({ + ca: 'some-ca', + host: 'host', + serviceAccountToken: { name: 'some-name', value: 'some-value' }, + }); + mockRouteParams.kibanaConfigWriter.writeConfig.mockRejectedValue( + new Error('Some error with sensitive path') + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + options: { + body: { + message: 'Failed to save configuration.', + attributes: { type: 'kibana_config_failure' }, + }, + statusCode: 500, + }, + payload: { + message: 'Failed to save configuration.', + attributes: { type: 'kibana_config_failure' }, + }, + }); + + expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1); + expect(mockRouteParams.preboot.completeSetup).not.toHaveBeenCalled(); + }); + + it('can successfully enrol and save configuration to the disk.', async () => { + mockRouteParams.preboot.isSetupOnHold.mockReturnValue(true); + mockRouteParams.elasticsearch.connectionStatus$.next( + ElasticsearchConnectionStatus.NotConfigured + ); + mockRouteParams.kibanaConfigWriter.isConfigWritable.mockResolvedValue(true); + mockRouteParams.elasticsearch.enroll.mockResolvedValue({ + ca: 'some-ca', + host: 'host', + serviceAccountToken: { name: 'some-name', value: 'some-value' }, + }); + mockRouteParams.kibanaConfigWriter.writeConfig.mockResolvedValue(); + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { apiKey: 'some-key', hosts: ['host1', 'host2'], caFingerprint: 'ab:cd:ef' }, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 204, + options: {}, + payload: undefined, + }); + + expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledTimes(1); + expect(mockRouteParams.elasticsearch.enroll).toHaveBeenCalledWith({ + apiKey: 'some-key', + hosts: ['host1', 'host2'], + caFingerprint: 'ab:cd:ef', + }); + + expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledTimes(1); + expect(mockRouteParams.kibanaConfigWriter.writeConfig).toHaveBeenCalledWith({ + ca: 'some-ca', + host: 'host', + serviceAccountToken: { name: 'some-name', value: 'some-value' }, + }); + + expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledTimes(1); + expect(mockRouteParams.preboot.completeSetup).toHaveBeenCalledWith({ + shouldReloadConfig: true, + }); + }); + }); +}); diff --git a/src/plugins/interactive_setup/server/routes/enroll.ts b/src/plugins/interactive_setup/server/routes/enroll.ts index a600d18109760..91b391bf8b109 100644 --- a/src/plugins/interactive_setup/server/routes/enroll.ts +++ b/src/plugins/interactive_setup/server/routes/enroll.ts @@ -6,26 +6,105 @@ * Side Public License, v 1. */ +import { first } from 'rxjs/operators'; + import { schema } from '@kbn/config-schema'; +import { ElasticsearchConnectionStatus } from '../../common'; +import type { EnrollResult } from '../elasticsearch_service'; import type { RouteDefinitionParams } from './'; /** * Defines routes to deal with Elasticsearch `enroll_kibana` APIs. */ -export function defineEnrollRoutes({ router }: RouteDefinitionParams) { +export function defineEnrollRoutes({ + router, + logger, + kibanaConfigWriter, + elasticsearch, + preboot, +}: RouteDefinitionParams) { router.post( { path: '/internal/interactive_setup/enroll', validate: { - body: schema.object({ token: schema.string() }), + body: schema.object({ + hosts: schema.arrayOf(schema.uri({ scheme: 'https' }), { + minSize: 1, + }), + apiKey: schema.string({ minLength: 1 }), + caFingerprint: schema.string({ maxLength: 64, minLength: 64 }), + }), }, options: { authRequired: false }, }, async (context, request, response) => { - return response.forbidden({ - body: { message: `API is not implemented yet.` }, - }); + if (!preboot.isSetupOnHold()) { + logger.error(`Invalid request to [path=${request.url.pathname}] outside of preboot stage`); + return response.badRequest({ body: 'Cannot process request outside of preboot stage.' }); + } + + const connectionStatus = await elasticsearch.connectionStatus$.pipe(first()).toPromise(); + if (connectionStatus === ElasticsearchConnectionStatus.Configured) { + logger.error( + `Invalid request to [path=${request.url.pathname}], Elasticsearch connection is already configured.` + ); + return response.badRequest({ + body: { + message: 'Elasticsearch connection is already configured.', + attributes: { type: 'elasticsearch_connection_configured' }, + }, + }); + } + + // The most probable misconfiguration case is when Kibana process isn't allowed to write to the + // Kibana configuration file. We'll still have to handle possible filesystem access errors + // when we actually write to the disk, but this preliminary check helps us to avoid unnecessary + // enrollment call and communicate that to the user early. + const isConfigWritable = await kibanaConfigWriter.isConfigWritable(); + if (!isConfigWritable) { + logger.error('Kibana process does not have enough permissions to write to config file'); + return response.customError({ + statusCode: 500, + body: { + message: 'Kibana process does not have enough permissions to write to config file.', + attributes: { type: 'kibana_config_not_writable' }, + }, + }); + } + + let enrollResult: EnrollResult; + try { + enrollResult = await elasticsearch.enroll({ + apiKey: request.body.apiKey, + hosts: request.body.hosts, + caFingerprint: request.body.caFingerprint, + }); + } catch { + // For security reasons, we shouldn't leak to the user whether Elasticsearch node couldn't process enrollment + // request or we just couldn't connect to any of the provided hosts. + return response.customError({ + statusCode: 500, + body: { message: 'Failed to enroll.', attributes: { type: 'enroll_failure' } }, + }); + } + + try { + await kibanaConfigWriter.writeConfig(enrollResult); + } catch { + // For security reasons, we shouldn't leak any filesystem related errors. + return response.customError({ + statusCode: 500, + body: { + message: 'Failed to save configuration.', + attributes: { type: 'kibana_config_failure' }, + }, + }); + } + + preboot.completeSetup({ shouldReloadConfig: true }); + + return response.noContent(); } ); } diff --git a/src/plugins/interactive_setup/server/routes/index.mock.ts b/src/plugins/interactive_setup/server/routes/index.mock.ts new file mode 100644 index 0000000000000..249d1277269e7 --- /dev/null +++ b/src/plugins/interactive_setup/server/routes/index.mock.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { coreMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; + +import { ConfigSchema } from '../config'; +import { elasticsearchServiceMock } from '../elasticsearch_service.mock'; +import { kibanaConfigWriterMock } from '../kibana_config_writer.mock'; + +export const routeDefinitionParamsMock = { + create: (config: Record = {}) => ({ + router: httpServiceMock.createRouter(), + basePath: httpServiceMock.createBasePath(), + csp: httpServiceMock.createSetupContract().csp, + logger: loggingSystemMock.create().get(), + preboot: { ...coreMock.createPreboot().preboot, completeSetup: jest.fn() }, + getConfig: jest.fn().mockReturnValue(ConfigSchema.validate(config)), + elasticsearch: elasticsearchServiceMock.createSetup(), + kibanaConfigWriter: kibanaConfigWriterMock.create(), + }), +}; diff --git a/src/plugins/interactive_setup/server/routes/index.ts b/src/plugins/interactive_setup/server/routes/index.ts index 0f14f5ffac8ec..752c5828ecb59 100644 --- a/src/plugins/interactive_setup/server/routes/index.ts +++ b/src/plugins/interactive_setup/server/routes/index.ts @@ -6,10 +6,12 @@ * Side Public License, v 1. */ -import type { IBasePath, IRouter, Logger } from 'src/core/server'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { IBasePath, IRouter, Logger, PrebootServicePreboot } from 'src/core/server'; -import type { ElasticsearchConnectionStatus } from '../../common'; import type { ConfigType } from '../config'; +import type { ElasticsearchServiceSetup } from '../elasticsearch_service'; +import type { KibanaConfigWriter } from '../kibana_config_writer'; import { defineEnrollRoutes } from './enroll'; /** @@ -19,8 +21,12 @@ export interface RouteDefinitionParams { readonly router: IRouter; readonly basePath: IBasePath; readonly logger: Logger; + readonly preboot: PrebootServicePreboot & { + completeSetup: (result: { shouldReloadConfig: boolean }) => void; + }; + readonly kibanaConfigWriter: PublicMethodsOf; + readonly elasticsearch: ElasticsearchServiceSetup; readonly getConfig: () => ConfigType; - readonly getElasticsearchConnectionStatus: () => ElasticsearchConnectionStatus; } export function defineRoutes(params: RouteDefinitionParams) { diff --git a/src/plugins/vis_default_editor/public/components/options/index.ts b/src/plugins/vis_default_editor/public/components/options/index.ts index 31b09977f5c99..62ce76014f9fc 100644 --- a/src/plugins/vis_default_editor/public/components/options/index.ts +++ b/src/plugins/vis_default_editor/public/components/options/index.ts @@ -16,3 +16,4 @@ export { RangeOption } from './range'; export { RequiredNumberInputOption } from './required_number_input'; export { TextInputOption } from './text_input'; export { PercentageModeOption } from './percentage_mode'; +export { LongLegendOptions } from './long_legend_options'; diff --git a/src/plugins/vis_default_editor/public/components/options/long_legend_options.test.tsx b/src/plugins/vis_default_editor/public/components/options/long_legend_options.test.tsx new file mode 100644 index 0000000000000..69994bb279278 --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/options/long_legend_options.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test/jest'; +import { LongLegendOptions, LongLegendOptionsProps } from './long_legend_options'; +import { EuiFieldNumber } from '@elastic/eui'; + +describe('LongLegendOptions', () => { + let props: LongLegendOptionsProps; + let component; + beforeAll(() => { + props = { + truncateLegend: true, + setValue: jest.fn(), + }; + }); + + it('renders the EuiFieldNumber', () => { + component = mountWithIntl(); + expect(component.find(EuiFieldNumber).length).toBe(1); + }); + + it('should call setValue when value is changes in the number input', () => { + component = mountWithIntl(); + const numberField = component.find(EuiFieldNumber); + numberField.props().onChange!(({ + target: { + value: 3, + }, + } as unknown) as React.ChangeEvent); + + expect(props.setValue).toHaveBeenCalledWith('maxLegendLines', 3); + }); + + it('input number should be disabled when truncate is false', () => { + props.truncateLegend = false; + component = mountWithIntl(); + const numberField = component.find(EuiFieldNumber); + + expect(numberField.props().disabled).toBeTruthy(); + }); +}); diff --git a/src/plugins/vis_default_editor/public/components/options/long_legend_options.tsx b/src/plugins/vis_default_editor/public/components/options/long_legend_options.tsx new file mode 100644 index 0000000000000..c06fb94376dbe --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/options/long_legend_options.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { SwitchOption } from './switch'; + +const MAX_TRUNCATE_LINES = 5; +const MIN_TRUNCATE_LINES = 1; + +export interface LongLegendOptionsProps { + setValue: (paramName: 'maxLegendLines' | 'truncateLegend', value: boolean | number) => void; + truncateLegend: boolean; + maxLegendLines?: number; + 'data-test-subj'?: string; +} + +function LongLegendOptions({ + 'data-test-subj': dataTestSubj, + setValue, + truncateLegend, + maxLegendLines, +}: LongLegendOptionsProps) { + return ( + <> + + + } + > + { + const val = Number(e.target.value); + setValue( + 'maxLegendLines', + Math.min(MAX_TRUNCATE_LINES, Math.max(val, MIN_TRUNCATE_LINES)) + ); + }} + /> + + + ); +} + +export { LongLegendOptions }; diff --git a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap index 6c43072b97c28..fb51717d1adc0 100644 --- a/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap +++ b/src/plugins/vis_type_pie/public/__snapshots__/pie_fn.test.ts.snap @@ -57,6 +57,7 @@ Object { "valuesFormat": "percent", }, "legendPosition": "right", + "maxLegendLines": true, "metric": Object { "accessor": 0, "aggType": "count", @@ -72,6 +73,7 @@ Object { }, "splitColumn": undefined, "splitRow": undefined, + "truncateLegend": true, }, "visData": Object { "columns": Array [ diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx index 524986524fd7e..d37f4c10ea9ea 100644 --- a/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx +++ b/src/plugins/vis_type_pie/public/editor/components/pie.test.tsx @@ -73,6 +73,20 @@ describe('PalettePicker', function () { }); }); + it('renders the long legend options for the elastic charts implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'pieLongLegendsOptions').length).toBe(1); + }); + }); + + it('not renders the long legend options for the vislib implementation', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'pieLongLegendsOptions').length).toBe(0); + }); + }); + it('renders the label position dropdown for the elastic charts implementation', async () => { component = mountWithIntl(); await act(async () => { diff --git a/src/plugins/vis_type_pie/public/editor/components/pie.tsx b/src/plugins/vis_type_pie/public/editor/components/pie.tsx index 8ce4f4defbaed..3bf28ba58d4eb 100644 --- a/src/plugins/vis_type_pie/public/editor/components/pie.tsx +++ b/src/plugins/vis_type_pie/public/editor/components/pie.tsx @@ -26,6 +26,7 @@ import { SwitchOption, SelectOption, PalettePicker, + LongLegendOptions, } from '../../../../vis_default_editor/public'; import { VisEditorOptionsProps } from '../../../../visualizations/public'; import { TruncateLabelsOption } from './truncate_labels'; @@ -169,6 +170,12 @@ const PieOptions = (props: PieOptionsProps) => { }} data-test-subj="visTypePieNestedLegendSwitch" /> + )} {props.showElasticChartsOptions && palettesRegistry && ( @@ -276,7 +283,13 @@ const PieOptions = (props: PieOptionsProps) => { /> )} - + ); diff --git a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx index e6eb56725753c..d4c798498e8b0 100644 --- a/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx +++ b/src/plugins/vis_type_pie/public/editor/components/truncate_labels.tsx @@ -8,7 +8,7 @@ import React, { ChangeEvent } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; +import { EuiFormRow, EuiFieldNumber, EuiIconTip } from '@elastic/eui'; export interface TruncateLabelsOptionProps { disabled?: boolean; @@ -27,6 +27,16 @@ function TruncateLabelsOption({ disabled, value = null, setValue }: TruncateLabe })} fullWidth display="rowCompressed" + labelAppend={ + + } > { }, legendPosition: 'right', nestedLegend: false, + maxLegendLines: 1, + truncateLegend: true, distinctColors: false, palette: { name: 'default', diff --git a/src/plugins/vis_type_pie/public/pie_component.tsx b/src/plugins/vis_type_pie/public/pie_component.tsx index c0f4a8a6112f8..9119f2f2ecd6c 100644 --- a/src/plugins/vis_type_pie/public/pie_component.tsx +++ b/src/plugins/vis_type_pie/public/pie_component.tsx @@ -320,7 +320,16 @@ const PieComponent = (props: PieComponentProps) => { services.actions, services.fieldFormats )} - theme={chartTheme} + theme={[ + chartTheme, + { + legend: { + labelOptions: { + maxLines: visParams.truncateLegend ? visParams.maxLegendLines ?? 1 : 0, + }, + }, + }, + ]} baseTheme={chartBaseTheme} onRenderChange={onRenderChange} /> diff --git a/src/plugins/vis_type_pie/public/pie_fn.test.ts b/src/plugins/vis_type_pie/public/pie_fn.test.ts index 3dcef406379c2..33b5f38cbe630 100644 --- a/src/plugins/vis_type_pie/public/pie_fn.test.ts +++ b/src/plugins/vis_type_pie/public/pie_fn.test.ts @@ -23,6 +23,8 @@ describe('interpreter/functions#pie', () => { legendPosition: 'right', isDonut: true, nestedLegend: true, + truncateLegend: true, + maxLegendLines: true, distinctColors: false, palette: 'kibana_palette', labels: { diff --git a/src/plugins/vis_type_pie/public/pie_fn.ts b/src/plugins/vis_type_pie/public/pie_fn.ts index 65ac648ca2868..c5987001d4494 100644 --- a/src/plugins/vis_type_pie/public/pie_fn.ts +++ b/src/plugins/vis_type_pie/public/pie_fn.ts @@ -89,6 +89,19 @@ export const createPieVisFn = (): VisTypePieExpressionFunctionDefinition => ({ }), default: false, }, + truncateLegend: { + types: ['boolean'], + help: i18n.translate('visTypePie.function.args.truncateLegendHelpText', { + defaultMessage: 'Defines if the legend items will be truncated or not', + }), + default: true, + }, + maxLegendLines: { + types: ['number'], + help: i18n.translate('visTypePie.function.args.maxLegendLinesHelpText', { + defaultMessage: 'Defines the number of lines per legend item', + }), + }, distinctColors: { types: ['boolean'], help: i18n.translate('visTypePie.function.args.distinctColorsHelpText', { diff --git a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts index 41fa00bbe2386..26d9c526a8137 100644 --- a/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_type_pie/public/sample_vis.test.mocks.ts @@ -28,6 +28,8 @@ export const samplePieVis = { legendPosition: 'right', isDonut: true, nestedLegend: true, + truncateLegend: true, + maxLegendLines: 1, distinctColors: false, palette: 'kibana_palette', labels: { diff --git a/src/plugins/vis_type_pie/public/to_ast.ts b/src/plugins/vis_type_pie/public/to_ast.ts index e8c9f301b4366..b360e375bf40d 100644 --- a/src/plugins/vis_type_pie/public/to_ast.ts +++ b/src/plugins/vis_type_pie/public/to_ast.ts @@ -50,6 +50,8 @@ export const toExpressionAst: VisToExpressionAst = async (vis, par addLegend: vis.params.addLegend, legendPosition: vis.params.legendPosition, nestedLegend: vis.params?.nestedLegend, + truncateLegend: vis.params.truncateLegend, + maxLegendLines: vis.params.maxLegendLines, distinctColors: vis.params?.distinctColors, isDonut: vis.params.isDonut, palette: vis.params?.palette?.name, diff --git a/src/plugins/vis_type_pie/public/types/types.ts b/src/plugins/vis_type_pie/public/types/types.ts index 4f3365545d062..94eaeb55f7242 100644 --- a/src/plugins/vis_type_pie/public/types/types.ts +++ b/src/plugins/vis_type_pie/public/types/types.ts @@ -33,6 +33,8 @@ interface PieCommonParams { addLegend: boolean; legendPosition: Position; nestedLegend: boolean; + truncateLegend: boolean; + maxLegendLines: number; distinctColors: boolean; isDonut: boolean; } diff --git a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts index 3170628ec2e12..9f64266ed0e0e 100644 --- a/src/plugins/vis_type_pie/public/utils/get_columns.test.ts +++ b/src/plugins/vis_type_pie/public/utils/get_columns.test.ts @@ -144,6 +144,8 @@ describe('getColumns', () => { }, legendPosition: 'right', nestedLegend: false, + maxLegendLines: 1, + truncateLegend: false, palette: { name: 'default', type: 'palette', diff --git a/src/plugins/vis_type_pie/public/utils/get_config.ts b/src/plugins/vis_type_pie/public/utils/get_config.ts index a8a4edb01cd9c..40f8f84b127f9 100644 --- a/src/plugins/vis_type_pie/public/utils/get_config.ts +++ b/src/plugins/vis_type_pie/public/utils/get_config.ts @@ -63,6 +63,7 @@ export const getConfig = ( config.linkLabel = { maxCount: Number.POSITIVE_INFINITY, maximumSection: Number.POSITIVE_INFINITY, + maxTextLength: visParams.labels.truncate ?? undefined, }; } diff --git a/src/plugins/vis_type_pie/public/utils/get_layers.ts b/src/plugins/vis_type_pie/public/utils/get_layers.ts index b995df83c0bb0..42c4650419c6b 100644 --- a/src/plugins/vis_type_pie/public/utils/get_layers.ts +++ b/src/plugins/vis_type_pie/public/utils/get_layers.ts @@ -151,12 +151,7 @@ export const getLayers = ( showAccessor: (d: Datum) => d !== EMPTY_SLICE, nodeLabel: (d: unknown) => { if (col.format) { - const formattedLabel = formatter.deserialize(col.format).convert(d) ?? ''; - if (visParams.labels.truncate && formattedLabel.length <= visParams.labels.truncate) { - return formattedLabel; - } else { - return `${formattedLabel.slice(0, Number(visParams.labels.truncate))}\u2026`; - } + return formatter.deserialize(col.format).convert(d) ?? ''; } return String(d); }, diff --git a/src/plugins/vis_type_pie/public/vis_type/pie.ts b/src/plugins/vis_type_pie/public/vis_type/pie.ts index 9d1556ac33ad7..95a9d0d41481b 100644 --- a/src/plugins/vis_type_pie/public/vis_type/pie.ts +++ b/src/plugins/vis_type_pie/public/vis_type/pie.ts @@ -35,6 +35,8 @@ export const getPieVisTypeDefinition = ({ addLegend: !showElasticChartsOptions, legendPosition: Position.Right, nestedLegend: false, + truncateLegend: true, + maxLegendLines: 1, distinctColors: false, isDonut: true, palette: { diff --git a/src/plugins/vis_type_timeseries/common/types/panel_model.ts b/src/plugins/vis_type_timeseries/common/types/panel_model.ts index 2ac9125534ac7..ff942a30abbdc 100644 --- a/src/plugins/vis_type_timeseries/common/types/panel_model.ts +++ b/src/plugins/vis_type_timeseries/common/types/panel_model.ts @@ -161,6 +161,8 @@ export interface Panel { series: Series[]; show_grid: number; show_legend: number; + truncate_legend?: number; + max_lines_legend?: number; time_field?: string; time_range_mode?: string; tooltip_mode?: TOOLTIP_MODES; diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.test.tsx new file mode 100644 index 0000000000000..02f28f3135880 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallowWithIntl as shallow } from '@kbn/test/jest'; + +jest.mock('../lib/get_default_query_language', () => ({ + getDefaultQueryLanguage: () => 'kuery', +})); + +import { TimeseriesPanelConfig } from './timeseries'; +import { PanelConfigProps } from './types'; + +describe('TimeseriesPanelConfig', () => { + it('sets the number input to the given value', () => { + const props = ({ + fields: {}, + model: { + max_lines_legend: 2, + }, + onChange: jest.fn(), + } as unknown) as PanelConfigProps; + const wrapper = shallow(); + wrapper.instance().setState({ selectedTab: 'options' }); + expect( + wrapper.find('[data-test-subj="timeSeriesEditorDataMaxLegendLines"]').prop('value') + ).toEqual(2); + }); + + it('switches on the truncate legend switch if the prop is set to 1 ', () => { + const props = ({ + fields: {}, + model: { + max_lines_legend: 2, + truncate_legend: 1, + }, + onChange: jest.fn(), + } as unknown) as PanelConfigProps; + const wrapper = shallow(); + wrapper.instance().setState({ selectedTab: 'options' }); + expect( + wrapper.find('[data-test-subj="timeSeriesEditorDataTruncateLegendSwitch"]').prop('value') + ).toEqual(1); + }); + + it('switches off the truncate legend switch if the prop is set to 0', () => { + const props = ({ + fields: {}, + model: { + max_lines_legend: 2, + truncate_legend: 0, + }, + onChange: jest.fn(), + } as unknown) as PanelConfigProps; + const wrapper = shallow(); + wrapper.instance().setState({ selectedTab: 'options' }); + expect( + wrapper.find('[data-test-subj="timeSeriesEditorDataTruncateLegendSwitch"]').prop('value') + ).toEqual(0); + }); + + it('disables the max lines number input if the truncate legend switch is off', () => { + const props = ({ + fields: {}, + model: { + max_lines_legend: 2, + truncate_legend: 0, + }, + onChange: jest.fn(), + } as unknown) as PanelConfigProps; + const wrapper = shallow(); + wrapper.instance().setState({ selectedTab: 'options' }); + expect( + wrapper.find('[data-test-subj="timeSeriesEditorDataMaxLegendLines"]').prop('disabled') + ).toEqual(true); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx index cdad8c1aeff4b..25e6c7906d831 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.tsx @@ -23,6 +23,7 @@ import { EuiFieldText, EuiTitle, EuiHorizontalRule, + EuiFieldNumber, } from '@elastic/eui'; // @ts-expect-error not typed yet @@ -102,6 +103,9 @@ const legendPositionOptions = [ }, ]; +const MAX_TRUNCATE_LINES = 5; +const MIN_TRUNCATE_LINES = 1; + export class TimeseriesPanelConfig extends Component< PanelConfigProps, { selectedTab: PANEL_CONFIG_TABS } @@ -344,7 +348,7 @@ export class TimeseriesPanelConfig extends Component< /> - + - - + - + - + + + + - + + + - - - + + + + + + - + + + + + + + + + + { + const val = Number(e.target.value); + this.props.onChange({ + max_lines_legend: Math.min( + MAX_TRUNCATE_LINES, + Math.max(val, MIN_TRUNCATE_LINES) + ), + }); + }} + /> + + + + + + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js index 097b0a7b5e332..d9440804701b2 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js @@ -238,6 +238,8 @@ class TimeseriesVisualization extends Component { showGrid={Boolean(model.show_grid)} legend={Boolean(model.show_legend)} legendPosition={model.legend_position} + truncateLegend={Boolean(model.truncate_legend)} + maxLegendLines={model.max_lines_legend} tooltipMode={model.tooltip_mode} xAxisFormatter={this.xAxisFormatter(interval)} annotations={this.prepareAnnotations()} diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index a818d1d5843de..b470352eec56a 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -56,6 +56,8 @@ export const TimeSeries = ({ showGrid, legend, legendPosition, + truncateLegend, + maxLegendLines, tooltipMode, series, yAxis, @@ -172,6 +174,9 @@ export const TimeSeries = ({ background: { color: backgroundColor, }, + legend: { + labelOptions: { maxLines: truncateLegend ? maxLegendLines ?? 1 : 0 }, + }, }, chartTheme, ]} @@ -216,6 +221,7 @@ export const TimeSeries = ({ lines, data, hideInLegend, + truncateLegend, xScaleType, yScaleType, groupId, @@ -249,6 +255,7 @@ export const TimeSeries = ({ name={getValueOrEmpty(seriesName)} data={data} hideInLegend={hideInLegend} + truncateLegend={truncateLegend} bars={bars} color={finalColor} stackAccessors={stackAccessors} @@ -274,6 +281,7 @@ export const TimeSeries = ({ name={getValueOrEmpty(seriesName)} data={data} hideInLegend={hideInLegend} + truncateLegend={truncateLegend} lines={lines} color={finalColor} stackAccessors={stackAccessors} @@ -336,6 +344,8 @@ TimeSeries.propTypes = { showGrid: PropTypes.bool, legend: PropTypes.bool, legendPosition: PropTypes.string, + truncateLegend: PropTypes.bool, + maxLegendLines: PropTypes.number, series: PropTypes.array, yAxis: PropTypes.array, onBrush: PropTypes.func, diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index d639604c7cd29..b68812b9828e3 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -93,6 +93,8 @@ export const metricsVisDefinition: VisTypeDefinition< axis_formatter: 'number', axis_scale: 'normal', show_legend: 1, + truncate_legend: 1, + max_lines_legend: 1, show_grid: 1, tooltip_mode: TOOLTIP_MODES.SHOW_ALL, drop_last_bucket: 0, diff --git a/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap index 8b720568c4d2c..233940d97d38a 100644 --- a/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_vislib/public/__snapshots__/to_ast.test.ts.snap @@ -8,7 +8,7 @@ Object { "area", ], "visConfig": Array [ - "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap index 7c21e699216bc..7ee1b0d2b2053 100644 --- a/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_xy/public/__snapshots__/to_ast.test.ts.snap @@ -32,6 +32,9 @@ Object { "legendPosition": Array [ "top", ], + "maxLegendLines": Array [ + 1, + ], "palette": Array [ "default", ], @@ -51,6 +54,9 @@ Object { }, ], "times": Array [], + "truncateLegend": Array [ + true, + ], "type": Array [ "area", ], diff --git a/src/plugins/vis_type_xy/public/components/xy_settings.tsx b/src/plugins/vis_type_xy/public/components/xy_settings.tsx index 03455bae69506..2dd7d7e0a91f9 100644 --- a/src/plugins/vis_type_xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_type_xy/public/components/xy_settings.tsx @@ -60,6 +60,8 @@ type XYSettingsProps = Pick< legendAction?: LegendAction; legendColorPicker: LegendColorPicker; legendPosition: Position; + truncateLegend: boolean; + maxLegendLines: number; }; function getValueLabelsStyling() { @@ -93,6 +95,8 @@ export const XYSettings: FC = ({ legendAction, legendColorPicker, legendPosition, + maxLegendLines, + truncateLegend, }) => { const themeService = getThemeService(); const theme = themeService.useChartsTheme(); @@ -113,6 +117,9 @@ export const XYSettings: FC = ({ crosshair: { ...theme.crosshair, }, + legend: { + labelOptions: { maxLines: truncateLegend ? maxLegendLines ?? 1 : 0 }, + }, axes: { axisTitle: { padding: { diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts index d5e1360ced74c..e51b47bc4c7fa 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.mocks.ts @@ -426,6 +426,8 @@ export const getVis = (bucketType: string) => { fittingFunction: 'linear', times: [], addTimeMarker: false, + maxLegendLines: 1, + truncateLegend: true, radiusRatio: 9, thresholdLine: { show: false, @@ -849,6 +851,8 @@ export const getStateParams = (type: string, thresholdPanelOn: boolean) => { legendPosition: 'right', times: [], addTimeMarker: false, + maxLegendLines: 1, + truncateLegend: true, detailedTooltip: true, palette: { type: 'palette', diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx index 59c03e02ac9f4..7fedd38e4e7ec 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.test.tsx @@ -105,6 +105,26 @@ describe('PointSeries Editor', function () { }); }); + it('not renders the long legend options if showElasticChartsOptions is false', async () => { + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'xyLongLegendsOptions').length).toBe(0); + }); + }); + + it('renders the long legend options if showElasticChartsOptions is true', async () => { + const newVisProps = ({ + ...props, + extraProps: { + showElasticChartsOptions: true, + }, + } as unknown) as PointSeriesOptionsProps; + component = mountWithIntl(); + await act(async () => { + expect(findTestSubject(component, 'xyLongLegendsOptions').length).toBe(1); + }); + }); + it('not renders the fitting function for a bar chart', async () => { const newVisProps = ({ ...props, diff --git a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx index 343976651d21e..1fd9b043e87f5 100644 --- a/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx +++ b/src/plugins/vis_type_xy/public/editor/components/options/point_series/point_series.tsx @@ -11,7 +11,11 @@ import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { BasicOptions, SwitchOption } from '../../../../../../vis_default_editor/public'; +import { + BasicOptions, + SwitchOption, + LongLegendOptions, +} from '../../../../../../vis_default_editor/public'; import { BUCKET_TYPES } from '../../../../../../data/public'; import { VisParams } from '../../../../types'; @@ -58,6 +62,14 @@ export function PointSeriesOptions( + {props.extraProps?.showElasticChartsOptions && ( + + )} {vis.data.aggs!.aggs.some( (agg) => agg.schema === 'segment' && agg.type.name === BUCKET_TYPES.DATE_HISTOGRAM diff --git a/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts b/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts index 35f3b2d7c627d..6d2b860066b07 100644 --- a/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts +++ b/src/plugins/vis_type_xy/public/expression_functions/xy_vis_fn.ts @@ -55,6 +55,18 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({ defaultMessage: 'Show time marker', }), }, + truncateLegend: { + types: ['boolean'], + help: i18n.translate('visTypeXy.function.args.truncateLegend.help', { + defaultMessage: 'Defines if the legend will be truncated or not', + }), + }, + maxLegendLines: { + types: ['number'], + help: i18n.translate('visTypeXy.function.args.args.maxLegendLines.help', { + defaultMessage: 'Defines the maximum lines per legend item', + }), + }, addLegend: { types: ['boolean'], help: i18n.translate('visTypeXy.function.args.addLegend.help', { @@ -225,6 +237,8 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({ addTooltip: args.addTooltip, legendPosition: args.legendPosition, addTimeMarker: args.addTimeMarker, + maxLegendLines: args.maxLegendLines, + truncateLegend: args.truncateLegend, categoryAxes: args.categoryAxes.map((categoryAxis) => ({ ...categoryAxis, type: categoryAxis.axisType, diff --git a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts index 8fafd4c723055..7fff29edfab51 100644 --- a/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_type_xy/public/sample_vis.test.mocks.ts @@ -88,6 +88,8 @@ export const sampleAreaVis = { legendPosition: 'right', times: [], addTimeMarker: false, + truncateLegend: true, + maxLegendLines: 1, thresholdLine: { show: false, value: 10, @@ -255,6 +257,8 @@ export const sampleAreaVis = { legendPosition: 'top', times: [], addTimeMarker: false, + truncateLegend: true, + maxLegendLines: 1, thresholdLine: { show: false, value: 10, diff --git a/src/plugins/vis_type_xy/public/to_ast.ts b/src/plugins/vis_type_xy/public/to_ast.ts index 9fec3f99ab39b..0b1eb5262d71a 100644 --- a/src/plugins/vis_type_xy/public/to_ast.ts +++ b/src/plugins/vis_type_xy/public/to_ast.ts @@ -194,6 +194,8 @@ export const toExpressionAst: VisToExpressionAst = async (vis, params type: vis.type.name as XyVisType, chartType: vis.params.type, addTimeMarker: vis.params.addTimeMarker, + truncateLegend: vis.params.truncateLegend, + maxLegendLines: vis.params.maxLegendLines, addLegend: vis.params.addLegend, addTooltip: vis.params.addTooltip, legendPosition: vis.params.legendPosition, diff --git a/src/plugins/vis_type_xy/public/types/param.ts b/src/plugins/vis_type_xy/public/types/param.ts index 421690f7fc6a9..0687bd2af2cd1 100644 --- a/src/plugins/vis_type_xy/public/types/param.ts +++ b/src/plugins/vis_type_xy/public/types/param.ts @@ -121,6 +121,8 @@ export interface VisParams { addTooltip: boolean; legendPosition: Position; addTimeMarker: boolean; + truncateLegend: boolean; + maxLegendLines: number; categoryAxes: CategoryAxis[]; orderBucketsBySum?: boolean; labels: Labels; @@ -158,6 +160,8 @@ export interface XYVisConfig { addTooltip: boolean; legendPosition: Position; addTimeMarker: boolean; + truncateLegend: boolean; + maxLegendLines: number; orderBucketsBySum?: boolean; labels: ExpressionValueLabel; thresholdLine: ExpressionValueThresholdLine; diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx index 2dffabb2ba0b9..346f6cc74a1ac 100644 --- a/src/plugins/vis_type_xy/public/vis_component.tsx +++ b/src/plugins/vis_type_xy/public/vis_component.tsx @@ -345,6 +345,8 @@ const VisComponent = (props: VisComponentProps) => { /> tr:nth-child(1)'); diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index fc0c0c6a48649..27407e9a0bc4d 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -34,8 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - // FLAKY: https://github.com/elastic/kibana/issues/100437 - describe.skip('field data', function () { + describe('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts index 97c1e678c4a9f..666377ae7f794 100644 --- a/test/functional/apps/discover/_field_data_with_fields_api.ts +++ b/test/functional/apps/discover/_field_data_with_fields_api.ts @@ -34,8 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - // FLAKY: https://github.com/elastic/kibana/issues/103389 - describe.skip('field data', function () { + describe('field data', function () { it('search php should show the correct hit count', async function () { const expectedHitCount = '445'; await retry.try(async function () { diff --git a/test/functional/apps/management/_runtime_fields.js b/test/functional/apps/management/_runtime_fields.js index 9a051bbdef6eb..745a3f9b079a4 100644 --- a/test/functional/apps/management/_runtime_fields.js +++ b/test/functional/apps/management/_runtime_fields.js @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['settings']); const testSubjects = getService('testSubjects'); - describe('runtime fields', function () { + // FLAKY: https://github.com/elastic/kibana/issues/95376 + describe.skip('runtime fields', function () { this.tags(['skipFirefox']); before(async function () { diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index a90e927416685..f4bf45c0b7f70 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -53,6 +53,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await button.focus(); await delay(10); await button.click(); + // Allow some time for the transition/animations to occur before assuming the click is done + await delay(10); }; describe('saved objects edition page', () => { diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index a4d8f884e1824..ae1b4fbf3179a 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -353,17 +353,39 @@ export class DiscoverPageObject extends FtrService { public async clickFieldListItemAdd(field: string) { // a filter check may make sense here, but it should be properly handled to make // it work with the _score and _source fields as well + if (await this.isFieldSelected(field)) { + return; + } await this.clickFieldListItemToggle(field); + const isLegacyDefault = await this.useLegacyTable(); + if (isLegacyDefault) { + await this.retry.waitFor(`field ${field} to be added to classic table`, async () => { + return await this.testSubjects.exists(`docTableHeader-${field}`); + }); + } else { + await this.retry.waitFor(`field ${field} to be added to new table`, async () => { + return await this.testSubjects.exists(`dataGridHeaderCell-${field}`); + }); + } } - public async clickFieldListItemRemove(field: string) { + public async isFieldSelected(field: string) { if (!(await this.testSubjects.exists('fieldList-selected'))) { - return; + return false; } const selectedList = await this.testSubjects.find('fieldList-selected'); - if (await this.testSubjects.descendantExists(`field-${field}`, selectedList)) { - await this.clickFieldListItemToggle(field); + return await this.testSubjects.descendantExists(`field-${field}`, selectedList); + } + + public async clickFieldListItemRemove(field: string) { + if ( + !(await this.testSubjects.exists('fieldList-selected')) || + !(await this.isFieldSelected(field)) + ) { + return; } + + await this.clickFieldListItemToggle(field); } public async clickFieldListItemVisualize(fieldName: string) { diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 1271fe5108f56..cf3a692d1622e 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -310,6 +310,7 @@ export class VisualizePageObject extends FtrService { if (navigateToVisualize) { await this.clickLoadSavedVisButton(); } + await this.listingTable.searchForItemWithName(vizName); await this.openSavedVisualization(vizName); } diff --git a/test/interpreter_functional/config.ts b/test/interpreter_functional/config.ts index c0ec982fb98b6..3f9c846a51429 100644 --- a/test/interpreter_functional/config.ts +++ b/test/interpreter_functional/config.ts @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); return { + rootTags: ['runOutsideOfCiGroups'], testFiles: [require.resolve('./test_suites/run_pipeline')], services: functionalConfig.get('services'), pageObjects: functionalConfig.get('pageObjects'), diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index e371518ce7fc7..8ac1633e61e49 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -20,6 +20,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); return { + rootTags: ['runOutsideOfCiGroups'], testFiles: [ require.resolve('./test_suites/usage_collection'), require.resolve('./test_suites/telemetry'), diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index 4b4dcaa64cd32..0705cb1062d8e 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -11,24 +11,7 @@ fi export KBN_NP_PLUGINS_BUILT=true echo " -> Ensuring all functional tests are in a ciGroup" -node scripts/ensure_all_tests_in_ci_group; - -echo " -> Ensuring all x-pack functional tests are in a ciGroup" -node x-pack/scripts/functional_tests --assert-none-excluded \ - --include-tag ciGroup1 \ - --include-tag ciGroup2 \ - --include-tag ciGroup3 \ - --include-tag ciGroup4 \ - --include-tag ciGroup5 \ - --include-tag ciGroup6 \ - --include-tag ciGroup7 \ - --include-tag ciGroup8 \ - --include-tag ciGroup9 \ - --include-tag ciGroup10 \ - --include-tag ciGroup11 \ - --include-tag ciGroup12 \ - --include-tag ciGroup13 \ - --include-tag ciGroupDocker +node scripts/ensure_all_tests_in_ci_group # Do not build kibana for code coverage run if [[ -z "$CODE_COVERAGE" ]] ; then diff --git a/test/scripts/jenkins_code_coverage.sh b/test/scripts/jenkins_code_coverage.sh index 98805e1209ec9..0931da5f9c4af 100755 --- a/test/scripts/jenkins_code_coverage.sh +++ b/test/scripts/jenkins_code_coverage.sh @@ -11,21 +11,4 @@ fi export KBN_NP_PLUGINS_BUILT=true echo " -> Ensuring all functional tests are in a ciGroup" -node scripts/ensure_all_tests_in_ci_group; - -echo " -> Ensuring all x-pack functional tests are in a ciGroup" -node x-pack/scripts/functional_tests --assert-none-excluded \ ---include-tag ciGroup1 \ ---include-tag ciGroup2 \ ---include-tag ciGroup3 \ ---include-tag ciGroup4 \ ---include-tag ciGroup5 \ ---include-tag ciGroup6 \ ---include-tag ciGroup7 \ ---include-tag ciGroup8 \ ---include-tag ciGroup9 \ ---include-tag ciGroup10 \ ---include-tag ciGroup11 \ ---include-tag ciGroup12 \ ---include-tag ciGroup13 \ ---include-tag ciGroupDocker +node scripts/ensure_all_tests_in_ci_group diff --git a/x-pack/plugins/alerting/common/alert.ts b/x-pack/plugins/alerting/common/alert.ts index 64c759752faec..1274e7b95b114 100644 --- a/x-pack/plugins/alerting/common/alert.ts +++ b/x-pack/plugins/alerting/common/alert.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server'; +import { + SavedObjectAttribute, + SavedObjectAttributes, + SavedObjectsResolveResponse, +} from 'kibana/server'; import { AlertNotifyWhenType } from './alert_notify_when_type'; export type AlertTypeState = Record; @@ -76,6 +80,8 @@ export interface Alert { } export type SanitizedAlert = Omit, 'apiKey'>; +export type ResolvedSanitizedRule = SanitizedAlert & + Omit; export type SanitizedRuleConfig = Pick< SanitizedAlert, diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index ad1c97efe2334..c1c7eae45109e 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -22,6 +22,7 @@ import { findRulesRoute } from './find_rules'; import { getRuleAlertSummaryRoute } from './get_rule_alert_summary'; import { getRuleStateRoute } from './get_rule_state'; import { healthRoute } from './health'; +import { resolveRuleRoute } from './resolve_rule'; import { ruleTypesRoute } from './rule_types'; import { muteAllRuleRoute } from './mute_all_rule'; import { muteAlertRoute } from './mute_alert'; @@ -42,6 +43,7 @@ export function defineRoutes(opts: RouteOptions) { defineLegacyRoutes(opts); createRuleRoute(opts); getRuleRoute(router, licenseState); + resolveRuleRoute(router, licenseState); updateRuleRoute(router, licenseState); deleteRuleRoute(router, licenseState); aggregateRulesRoute(router, licenseState); diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts new file mode 100644 index 0000000000000..b03369a74b865 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pick } from 'lodash'; +import { resolveRuleRoute } from './resolve_rule'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { rulesClientMock } from '../rules_client.mock'; +import { ResolvedSanitizedRule } from '../types'; +import { AsApiContract } from './lib'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('resolveRuleRoute', () => { + const mockedRule: ResolvedSanitizedRule<{ + bar: boolean; + }> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }; + + const resolveResult: AsApiContract> = { + ...pick( + mockedRule, + 'consumer', + 'name', + 'schedule', + 'tags', + 'params', + 'throttle', + 'enabled', + 'alias_target_id' + ), + rule_type_id: mockedRule.alertTypeId, + notify_when: mockedRule.notifyWhen, + mute_all: mockedRule.muteAll, + created_by: mockedRule.createdBy, + updated_by: mockedRule.updatedBy, + api_key_owner: mockedRule.apiKeyOwner, + muted_alert_ids: mockedRule.mutedInstanceIds, + created_at: mockedRule.createdAt, + updated_at: mockedRule.updatedAt, + id: mockedRule.id, + execution_status: { + status: mockedRule.executionStatus.status, + last_execution_date: mockedRule.executionStatus.lastExecutionDate, + }, + actions: [ + { + group: mockedRule.actions[0].group, + id: mockedRule.actions[0].id, + params: mockedRule.actions[0].params, + connector_type_id: mockedRule.actions[0].actionTypeId, + }, + ], + outcome: 'aliasMatch', + }; + + it('resolves a rule with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + resolveRuleRoute(router, licenseState); + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_resolve"`); + + rulesClient.resolve.mockResolvedValueOnce(mockedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + await handler(context, req, res); + + expect(rulesClient.resolve).toHaveBeenCalledTimes(1); + expect(rulesClient.resolve.mock.calls[0][0].id).toEqual('1'); + + expect(res.ok).toHaveBeenCalledWith({ + body: resolveResult, + }); + }); + + it('ensures the license allows resolving rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + resolveRuleRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.resolve.mockResolvedValueOnce(mockedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + it('ensures the license check prevents getting rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('OMG'); + }); + + resolveRuleRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.resolve.mockResolvedValueOnce(mockedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/resolve_rule.ts b/x-pack/plugins/alerting/server/routes/resolve_rule.ts new file mode 100644 index 0000000000000..011d28780e718 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/resolve_rule.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ILicenseState } from '../lib'; +import { verifyAccessAndContext, RewriteResponseCase } from './lib'; +import { + AlertTypeParams, + AlertingRequestHandlerContext, + INTERNAL_BASE_ALERTING_API_PATH, + ResolvedSanitizedRule, +} from '../types'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const rewriteBodyRes: RewriteResponseCase> = ({ + alertTypeId, + createdBy, + updatedBy, + createdAt, + updatedAt, + apiKeyOwner, + notifyWhen, + muteAll, + mutedInstanceIds, + executionStatus, + actions, + scheduledTaskId, + ...rest +}) => ({ + ...rest, + rule_type_id: alertTypeId, + created_by: createdBy, + updated_by: updatedBy, + created_at: createdAt, + updated_at: updatedAt, + api_key_owner: apiKeyOwner, + notify_when: notifyWhen, + mute_all: muteAll, + muted_alert_ids: mutedInstanceIds, + scheduled_task_id: scheduledTaskId, + execution_status: executionStatus && { + ...omit(executionStatus, 'lastExecutionDate'), + last_execution_date: executionStatus.lastExecutionDate, + }, + actions: actions.map(({ group, id, actionTypeId, params }) => ({ + group, + id, + params, + connector_type_id: actionTypeId, + })), +}); + +export const resolveRuleRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_resolve`, + validate: { + params: paramSchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = context.alerting.getRulesClient(); + const { id } = req.params; + const rule = await rulesClient.resolve({ id }); + return res.ok({ + body: rewriteBodyRes(rule), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 4bd197e51a5da..438331a1cd580 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -16,6 +16,7 @@ const createRulesClientMock = () => { aggregate: jest.fn(), create: jest.fn(), get: jest.fn(), + resolve: jest.fn(), getAlertState: jest.fn(), find: jest.fn(), delete: jest.fn(), diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index f04b7c3701974..5f6122458ddaf 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -11,6 +11,7 @@ import { AuditEvent } from '../../../security/server'; export enum RuleAuditAction { CREATE = 'rule_create', GET = 'rule_get', + RESOLVE = 'rule_resolve', UPDATE = 'rule_update', UPDATE_API_KEY = 'rule_update_api_key', ENABLE = 'rule_enable', @@ -28,6 +29,7 @@ type VerbsTuple = [string, string, string]; const eventVerbs: Record = { rule_create: ['create', 'creating', 'created'], rule_get: ['access', 'accessing', 'accessed'], + rule_resolve: ['access', 'accessing', 'accessed'], rule_update: ['update', 'updating', 'updated'], rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], rule_enable: ['enable', 'enabling', 'enabled'], @@ -43,6 +45,7 @@ const eventVerbs: Record = { const eventTypes: Record = { rule_create: 'creation', rule_get: 'access', + rule_resolve: 'access', rule_update: 'change', rule_update_api_key: 'change', rule_enable: 'change', diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index a079a52448e2d..486cf086b4a73 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -5,6 +5,7 @@ * 2.0. */ +import Semver from 'semver'; import Boom from '@hapi/boom'; import { omit, isEqual, map, uniq, pick, truncate, trim } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -33,6 +34,7 @@ import { AlertExecutionStatusValues, AlertNotifyWhenType, AlertTypeParams, + ResolvedSanitizedRule, } from '../types'; import { validateAlertTypeParams, @@ -296,11 +298,13 @@ export class RulesClient { ); const createTime = Date.now(); + const legacyId = Semver.lt(this.kibanaVersion, '8.0.0') ? id : null; const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); const rawAlert: RawAlert = { ...data, ...this.apiKeyAsAlertAttributes(createdAPIKey, username), + legacyId, actions, createdBy: username, updatedBy: username, @@ -411,6 +415,52 @@ export class RulesClient { ); } + public async resolve({ + id, + }: { + id: string; + }): Promise> { + const { + saved_object: result, + ...resolveResponse + } = await this.unsecuredSavedObjectsClient.resolve('alert', id); + try { + await this.authorization.ensureAuthorized({ + ruleTypeId: result.attributes.alertTypeId, + consumer: result.attributes.consumer, + operation: ReadOperations.Get, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.RESOLVE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.RESOLVE, + savedObject: { type: 'alert', id }, + }) + ); + + const rule = this.getAlertFromRaw( + result.id, + result.attributes.alertTypeId, + result.attributes, + result.references + ); + + return { + ...rule, + ...resolveResponse, + }; + } + public async getAlertState({ id }: { id: string }): Promise { const alert = await this.get({ id }); await this.authorization.ensureAuthorized({ @@ -1492,36 +1542,29 @@ export class RulesClient { notifyWhen, scheduledTaskId, params, - ...rawAlert + legacyId, // exclude from result because it is an internal variable + executionStatus, + schedule, + actions, + ...partialRawAlert }: Partial, references: SavedObjectReference[] | undefined ): PartialAlert { - // Not the prettiest code here, but if we want to use most of the - // alert fields from the rawAlert using `...rawAlert` kind of access, we - // need to specifically delete the executionStatus as it's a different type - // in RawAlert and Alert. Probably next time we need to do something similar - // here, we should look at redesigning the implementation of this method. - const rawAlertWithoutExecutionStatus: Partial> = { - ...rawAlert, - }; - delete rawAlertWithoutExecutionStatus.executionStatus; - const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus); - return { id, notifyWhen, - ...rawAlertWithoutExecutionStatus, + ...partialRawAlert, // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change - schedule: rawAlert.schedule as IntervalSchedule, - actions: rawAlert.actions - ? this.injectReferencesIntoActions(id, rawAlert.actions, references || []) - : [], + schedule: schedule as IntervalSchedule, + actions: actions ? this.injectReferencesIntoActions(id, actions, references || []) : [], params: this.injectReferencesIntoParams(id, ruleType, params, references || []) as Params, ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), ...(createdAt ? { createdAt: new Date(createdAt) } : {}), ...(scheduledTaskId ? { scheduledTaskId } : {}), - ...(executionStatus ? { executionStatus } : {}), + ...(executionStatus + ? { executionStatus: alertExecutionStatusFromRaw(this.logger, id, executionStatus) } + : {}), }; } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 944dcc29ff933..001604d68c46b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -35,7 +35,7 @@ const authorization = alertingAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); -const kibanaVersion = 'v7.10.0'; +const kibanaVersion = 'v8.0.0'; const rulesClientParams: jest.Mocked = { taskManager, ruleTypeRegistry, @@ -116,6 +116,19 @@ describe('create()', () => { isPreconfigured: false, }, ]); + taskManager.schedule.mockResolvedValue({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); }); @@ -154,19 +167,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -319,19 +319,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -418,8 +405,9 @@ describe('create()', () => { "lastExecutionDate": "2019-02-12T21:01:22.479Z", "status": "pending", }, + "legacyId": null, "meta": Object { - "versionApiKeyLastmodified": "v7.10.0", + "versionApiKeyLastmodified": "v8.0.0", }, "muteAll": false, "mutedInstanceIds": Array [], @@ -524,19 +512,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); const result = await rulesClient.create({ data, options: { id: '123' } }); expect(result.id).toEqual('123'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` @@ -553,6 +528,99 @@ describe('create()', () => { `); }); + test('sets legacyId when kibanaVersion is < 8.0.0', async () => { + const customrulesClient = new RulesClient({ + ...rulesClientParams, + kibanaVersion: 'v7.10.0', + }); + const data = getMockData(); + const createdAttributes = { + ...data, + legacyId: '123', + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '123', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + const result = await customrulesClient.create({ data, options: { id: '123' } }); + expect(result.id).toEqual('123'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "actionTypeId": "test", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "apiKey": null, + "apiKeyOwner": null, + "consumer": "bar", + "createdAt": "2019-02-12T21:01:22.479Z", + "createdBy": "elastic", + "enabled": true, + "executionStatus": Object { + "error": null, + "lastExecutionDate": "2019-02-12T21:01:22.479Z", + "status": "pending", + }, + "legacyId": "123", + "meta": Object { + "versionApiKeyLastmodified": "v7.10.0", + }, + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": "2019-02-12T21:01:22.479Z", + "updatedBy": "elastic", + } + `); + }); + test('creates an alert with multiple actions', async () => { const data = getMockData({ actions: [ @@ -669,19 +737,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -878,19 +933,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -916,12 +958,13 @@ describe('create()', () => { createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', enabled: true, + legacyId: null, executionStatus: { error: null, lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', }, - meta: { versionApiKeyLastmodified: 'v7.10.0' }, + meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, mutedInstanceIds: [], name: 'abc', @@ -1055,19 +1098,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -1089,6 +1119,7 @@ describe('create()', () => { alertTypeId: '123', apiKey: null, apiKeyOwner: null, + legacyId: null, consumer: 'bar', createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', @@ -1098,7 +1129,7 @@ describe('create()', () => { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', }, - meta: { versionApiKeyLastmodified: 'v7.10.0' }, + meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, mutedInstanceIds: [], name: 'abc', @@ -1189,19 +1220,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); await rulesClient.create({ data }); expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: 123/my alert name'); @@ -1246,19 +1264,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); const result = await rulesClient.create({ data }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( 'alert', @@ -1274,6 +1279,7 @@ describe('create()', () => { alertTypeId: '123', consumer: 'bar', name: 'abc', + legacyId: null, params: { bar: true }, apiKey: null, apiKeyOwner: null, @@ -1283,7 +1289,7 @@ describe('create()', () => { updatedAt: '2019-02-12T21:01:22.479Z', enabled: true, meta: { - versionApiKeyLastmodified: 'v7.10.0', + versionApiKeyLastmodified: kibanaVersion, }, schedule: { interval: '10s' }, throttle: '10m', @@ -1386,19 +1392,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); const result = await rulesClient.create({ data }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( 'alert', @@ -1411,6 +1404,7 @@ describe('create()', () => { params: { foo: true }, }, ], + legacyId: null, alertTypeId: '123', consumer: 'bar', name: 'abc', @@ -1423,7 +1417,7 @@ describe('create()', () => { updatedAt: '2019-02-12T21:01:22.479Z', enabled: true, meta: { - versionApiKeyLastmodified: 'v7.10.0', + versionApiKeyLastmodified: kibanaVersion, }, schedule: { interval: '10s' }, throttle: '10m', @@ -1526,19 +1520,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); const result = await rulesClient.create({ data }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( 'alert', @@ -1551,6 +1532,7 @@ describe('create()', () => { params: { foo: true }, }, ], + legacyId: null, alertTypeId: '123', consumer: 'bar', name: 'abc', @@ -1563,7 +1545,7 @@ describe('create()', () => { updatedAt: '2019-02-12T21:01:22.479Z', enabled: true, meta: { - versionApiKeyLastmodified: 'v7.10.0', + versionApiKeyLastmodified: kibanaVersion, }, schedule: { interval: '10s' }, throttle: null, @@ -1826,19 +1808,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -1871,6 +1840,7 @@ describe('create()', () => { alertTypeId: '123', consumer: 'bar', name: 'abc', + legacyId: null, params: { bar: true }, apiKey: Buffer.from('123:abc').toString('base64'), apiKeyOwner: 'elastic', @@ -1880,7 +1850,7 @@ describe('create()', () => { updatedAt: '2019-02-12T21:01:22.479Z', enabled: true, meta: { - versionApiKeyLastmodified: 'v7.10.0', + versionApiKeyLastmodified: kibanaVersion, }, schedule: { interval: '10s' }, throttle: null, @@ -1937,19 +1907,6 @@ describe('create()', () => { }, ], }); - taskManager.schedule.mockResolvedValueOnce({ - id: 'task-123', - taskType: 'alerting:123', - scheduledAt: new Date(), - attempts: 1, - status: TaskStatus.Idle, - runAt: new Date(), - startedAt: null, - retryAt: null, - state: {}, - params: {}, - ownerId: null, - }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -1979,6 +1936,7 @@ describe('create()', () => { params: { foo: true }, }, ], + legacyId: null, alertTypeId: '123', consumer: 'bar', name: 'abc', @@ -1991,7 +1949,7 @@ describe('create()', () => { updatedAt: '2019-02-12T21:01:22.479Z', enabled: false, meta: { - versionApiKeyLastmodified: 'v7.10.0', + versionApiKeyLastmodified: kibanaVersion, }, schedule: { interval: '10s' }, throttle: null, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts index d946c354872a7..f8414b08f191b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_instance_summary.test.ts @@ -72,6 +72,7 @@ const BaseAlertInstanceSummarySavedObject: SavedObject = { tags: ['tag-1', 'tag-2'], alertTypeId: '123', consumer: 'alert-consumer', + legacyId: null, schedule: { interval: `${AlertInstanceSummaryIntervalSeconds}s` }, actions: [], params: {}, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts new file mode 100644 index 0000000000000..63feb4ff3147a --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts @@ -0,0 +1,451 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; +import { getBeforeSetup, setGlobalDate } from './lib'; +import { RecoveredActionGroup } from '../../../common'; + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); + +const kibanaVersion = 'v7.10.0'; +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: (authorization as unknown) as AlertingAuthorization, + actionsAuthorization: (actionsAuthorization as unknown) as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +describe('resolve()', () => { + test('calls saved objects client with given params', async () => { + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + const result = await rulesClient.resolve({ id: '1' }); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alias_target_id": "2", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": "onActiveAlert", + "outcome": "aliasMatch", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + expect(unsecuredSavedObjectsClient.resolve).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.resolve.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alert", + "1", + ] + `); + }); + + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '9', + }); + ruleTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + const result = await rulesClient.resolve({ id: '1' }); + + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alias_target_id": "2", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": "onActiveAlert", + "outcome": "aliasMatch", + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "9", + }, + "schedule": Object { + "interval": "10s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); + + test(`throws an error when references aren't found`, async () => { + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action reference \\"action_0\\" not found in alert id: 1"` + ); + }); + + test('throws an error if useSavedObjectReferences.injectReferences throws an error', async () => { + const injectReferencesFn = jest.fn().mockImplementation(() => { + throw new Error('something went wrong!'); + }); + ruleTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error injecting reference into rule params for rule id 1 - something went wrong!"` + ); + }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + consumer: 'myApp', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + }); + + test('ensures user is authorised to resolve this type of rule under the consumer', async () => { + const rulesClient = new RulesClient(rulesClientParams); + await rulesClient.resolve({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'get', + ruleTypeId: 'myType', + }); + }); + + test('throws when user is not authorised to get this type of alert', async () => { + const rulesClient = new RulesClient(rulesClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "myType" alert for "myApp"`) + ); + + await expect(rulesClient.resolve({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'get', + ruleTypeId: 'myType', + }); + }); + }); + + describe('auditLogger', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + }, + references: [], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + }); + + test('logs audit event when getting a rule', async () => { + const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger }); + await rulesClient.resolve({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_resolve', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a rule', async () => { + const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(rulesClient.resolve({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_resolve', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.json b/x-pack/plugins/alerting/server/saved_objects/mappings.json index 43292c6a54346..21d7a05f2a76d 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.json @@ -28,6 +28,9 @@ "consumer": { "type": "keyword" }, + "legacyId": { + "type": "keyword" + }, "actions": { "type": "nested", "properties": { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index b1460a5fe5cd8..c9a9d7c73a8a6 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -1416,6 +1416,20 @@ describe('successful migrations', () => { }); }); }); + + describe('7.16.0', () => { + test('add legacyId field to alert - set to SavedObject id attribute', () => { + const migration716 = getMigrations(encryptedSavedObjectsSetup)['7.16.0']; + const alert = getMockData({}, true); + expect(migration716(alert, migrationContext)).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + legacyId: alert.id, + }, + }); + }); + }); }); describe('handles errors during migrations', () => { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 6823a9b9b20da..d53943991b215 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -99,6 +99,12 @@ export function getMigrations( pipeMigrations(addExceptionListsToReferences) ); + const migrateLegacyIds716 = createEsoMigration( + encryptedSavedObjects, + (doc): doc is SavedObjectUnsanitizedDoc => true, + pipeMigrations(setLegacyId) + ); + return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), @@ -106,6 +112,7 @@ export function getMigrations( '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), + '7.16.0': executeMigrationWithErrorHandling(migrateLegacyIds716, '7.16.0'), }; } @@ -567,6 +574,19 @@ function removeMalformedExceptionsList( } } +function setLegacyId( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { id } = doc; + return { + ...doc, + attributes: { + ...doc.attributes, + legacyId: id, + }, + }; +} + function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts index 5997df2895761..8236c4455478c 100644 --- a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.test.ts @@ -35,6 +35,7 @@ describe('transform rule for export', () => { apiKey: '4tndskbuhewotw4klrhgjewrt9u', apiKeyOwner: 'me', throttle: null, + legacyId: '1', notifyWhen: 'onActionGroupChange', muteAll: false, mutedInstanceIds: [], @@ -66,6 +67,7 @@ describe('transform rule for export', () => { apiKey: null, apiKeyOwner: null, throttle: null, + legacyId: '2', notifyWhen: 'onActionGroupChange', muteAll: false, mutedInstanceIds: [], @@ -90,6 +92,7 @@ describe('transform rule for export', () => { apiKey: null, apiKeyOwner: null, scheduledTaskId: null, + legacyId: null, executionStatus: { status: 'pending', lastExecutionDate: '2020-08-20T19:23:38Z', diff --git a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts index 707bd84e948bf..97fd226b49e8e 100644 --- a/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts +++ b/x-pack/plugins/alerting/server/saved_objects/transform_rule_for_export.ts @@ -22,6 +22,7 @@ function transformRuleForExport( ...rule, attributes: { ...rule.attributes, + legacyId: null, enabled: false, apiKey: null, apiKeyOwner: null, diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 8b8fce7a1bf62..67565271fedc8 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -198,6 +198,7 @@ export interface RawAlert extends SavedObjectAttributes { tags: string[]; alertTypeId: string; consumer: string; + legacyId: string | null; schedule: SavedObjectAttributes; actions: RawAlertAction[]; params: SavedObjectAttributes; diff --git a/x-pack/plugins/apm/ftr_e2e/config.ts b/x-pack/plugins/apm/ftr_e2e/config.ts index fc8fb2d5fe292..36acc4a93ecbb 100644 --- a/x-pack/plugins/apm/ftr_e2e/config.ts +++ b/x-pack/plugins/apm/ftr_e2e/config.ts @@ -33,6 +33,7 @@ async function config({ readConfigFile }: FtrConfigProviderContext) { ...xpackFunctionalTestsConfig.get('kbnTestServer'), serverArgs: [ ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--home.disableWelcomeScreen=true', '--csp.strict=false', // define custom kibana server args here `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts index e26261035c084..9d4c773422cdc 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/home.spec.ts @@ -7,7 +7,6 @@ import url from 'url'; import archives_metadata from '../../fixtures/es_archiver/archives_metadata'; -import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; const { start, end } = archives_metadata['apm_8.0.0']; @@ -28,15 +27,10 @@ const apisToIntercept = [ ]; describe('Home page', () => { - before(() => { - esArchiverLoad('apm_8.0.0'); - }); - after(() => { - esArchiverUnload('apm_8.0.0'); - }); beforeEach(() => { cy.loginAsReadOnlyUser(); }); + it('Redirects to service page with rangeFrom and rangeTo added to the URL', () => { cy.visit('/app/apm'); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts index f124b3818c193..7e8d2d02b1f82 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/header_filters.spec.ts @@ -6,7 +6,6 @@ */ import url from 'url'; import archives_metadata from '../../../fixtures/es_archiver/archives_metadata'; -import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver'; const { start, end } = archives_metadata['apm_8.0.0']; @@ -59,15 +58,10 @@ const apisToIntercept = [ ]; describe('Service overview - header filters', () => { - before(() => { - esArchiverLoad('apm_8.0.0'); - }); - after(() => { - esArchiverUnload('apm_8.0.0'); - }); beforeEach(() => { cy.loginAsReadOnlyUser(); }); + describe('Filtering by transaction type', () => { it('changes url when selecting different value', () => { cy.visit(serviceOverviewHref); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts index 9428f9b9e6bb6..d972602d9e496 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/instances_table.spec.ts @@ -7,7 +7,6 @@ import url from 'url'; import archives_metadata from '../../../fixtures/es_archiver/archives_metadata'; -import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver'; const { start, end } = archives_metadata['apm_8.0.0']; @@ -43,25 +42,21 @@ describe('Instances table', () => { beforeEach(() => { cy.loginAsReadOnlyUser(); }); - describe('when data is not loaded', () => { - it('shows empty message', () => { - cy.visit(serviceOverviewHref); - cy.contains('opbeans-java'); - cy.get('[data-test-subj="serviceInstancesTableContainer"]').contains( - 'No items found' - ); - }); - }); + + // describe('when data is not loaded', () => { + // it('shows empty message', () => { + // cy.visit(serviceOverviewHref); + // cy.contains('opbeans-java'); + // cy.get('[data-test-subj="serviceInstancesTableContainer"]').contains( + // 'No items found' + // ); + // }); + // }); describe('when data is loaded', () => { - before(() => { - esArchiverLoad('apm_8.0.0'); - }); - after(() => { - esArchiverUnload('apm_8.0.0'); - }); const serviceNodeName = '31651f3c624b81c55dd4633df0b5b9f9ab06b151121b0404ae796632cd1f87ad'; + it('has data in the table', () => { cy.visit(serviceOverviewHref); cy.contains('opbeans-java'); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts index 7c5d5988c9bf6..e3670d77c1143 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts @@ -7,7 +7,6 @@ import url from 'url'; import archives_metadata from '../../../fixtures/es_archiver/archives_metadata'; -import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver'; const { start, end } = archives_metadata['apm_8.0.0']; @@ -18,15 +17,10 @@ const baseUrl = url.format({ }); describe('Service Overview', () => { - before(() => { - esArchiverLoad('apm_8.0.0'); - }); - after(() => { - esArchiverUnload('apm_8.0.0'); - }); beforeEach(() => { cy.loginAsReadOnlyUser(); }); + it('persists transaction type selected when clicking on Transactions tab', () => { cy.visit(baseUrl); cy.get('[data-test-subj="headerFilterTransactionType"]').should( diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts index de05cc3abb927..5b6cb08e21ebb 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/time_comparison.spec.ts @@ -7,7 +7,6 @@ import url from 'url'; import moment from 'moment'; import archives_metadata from '../../../fixtures/es_archiver/archives_metadata'; -import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver'; const { start, end } = archives_metadata['apm_8.0.0']; @@ -47,12 +46,6 @@ const apisToIntercept = [ ]; describe('Service overview: Time Comparison', () => { - before(() => { - esArchiverLoad('apm_8.0.0'); - }); - after(() => { - esArchiverUnload('apm_8.0.0'); - }); beforeEach(() => { cy.loginAsReadOnlyUser(); }); diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts index eaa0ee9e4d65a..9180e6371fda7 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/transactions_overview/transactions_overview.spec.ts @@ -7,7 +7,6 @@ import url from 'url'; import archives_metadata from '../../../fixtures/es_archiver/archives_metadata'; -import { esArchiverLoad, esArchiverUnload } from '../../../tasks/es_archiver'; const { start, end } = archives_metadata['apm_8.0.0']; @@ -17,15 +16,10 @@ const serviceOverviewHref = url.format({ }); describe('Transactions Overview', () => { - before(() => { - esArchiverLoad('apm_8.0.0'); - }); - after(() => { - esArchiverUnload('apm_8.0.0'); - }); beforeEach(() => { cy.loginAsReadOnlyUser(); }); + it('persists transaction type selected when navigating to Overview tab', () => { cy.visit(serviceOverviewHref); cy.get('[data-test-subj="headerFilterTransactionType"]').should( diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts index 3dbe36647a851..e2025c01d4c7a 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/support/index.ts @@ -5,4 +5,12 @@ * 2.0. */ +Cypress.on('uncaught:exception', (err, runnable) => { + // @see https://stackoverflow.com/a/50387233/434980 + // ResizeObserver error can be safely ignored + if (err.message.includes('ResizeObserver loop limit exceeded')) { + return false; + } +}); + import './commands'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts index 3912b60dd56ed..5e4dd9f8657ff 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/tasks/es_archiver.ts @@ -6,31 +6,32 @@ */ import Path from 'path'; +import { execSync } from 'child_process'; const ES_ARCHIVE_DIR = './cypress/fixtures/es_archiver'; -// Otherwise cy.exec would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https +// Otherwise execSync would inject NODE_TLS_REJECT_UNAUTHORIZED=0 and node would abort if used over https const NODE_TLS_REJECT_UNAUTHORIZED = '1'; export const esArchiverLoad = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); - cy.exec( + execSync( `node ../../../../scripts/es_archiver load "${path}" --config ../../../test/functional/config.js`, - { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverUnload = (folder: string) => { const path = Path.join(ES_ARCHIVE_DIR, folder); - cy.exec( + execSync( `node ../../../../scripts/es_archiver unload "${path}" --config ../../../test/functional/config.js`, - { env: { NODE_TLS_REJECT_UNAUTHORIZED } } + { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED } } ); }; export const esArchiverResetKibana = () => { - cy.exec( + execSync( `node ../../../../scripts/es_archiver empty-kibana-index --config ../../../test/functional/config.js`, - { env: { NODE_TLS_REJECT_UNAUTHORIZED }, failOnNonZeroExit: false } + { env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED } } ); }; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_open.ts b/x-pack/plugins/apm/ftr_e2e/cypress_open.ts index ec52f387a8b98..3f7758b40b90d 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress_open.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress_open.ts @@ -9,9 +9,9 @@ import { FtrConfigProviderContext } from '@kbn/test'; import { cypressOpenTests } from './cypress_start'; async function openE2ETests({ readConfigFile }: FtrConfigProviderContext) { - const cypressConfig = await readConfigFile(require.resolve('./config.ts')); + const kibanaConfig = await readConfigFile(require.resolve('./config.ts')); return { - ...cypressConfig.getAll(), + ...kibanaConfig.getAll(), testRunner: cypressOpenTests, }; } diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_run.ts b/x-pack/plugins/apm/ftr_e2e/cypress_run.ts index eb319f4b30835..16f93b39910f3 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress_run.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress_run.ts @@ -8,13 +8,13 @@ import { argv } from 'yargs'; import { FtrConfigProviderContext } from '@kbn/test'; import { cypressRunTests } from './cypress_start'; -const spec = argv.grep as string; +const specArg = argv.spec as string | undefined; async function runE2ETests({ readConfigFile }: FtrConfigProviderContext) { - const cypressConfig = await readConfigFile(require.resolve('./config.ts')); + const kibanaConfig = await readConfigFile(require.resolve('./config.ts')); return { - ...cypressConfig.getAll(), - testRunner: cypressRunTests(spec), + ...kibanaConfig.getAll(), + testRunner: cypressRunTests(specArg), }; } diff --git a/x-pack/plugins/apm/ftr_e2e/cypress_start.ts b/x-pack/plugins/apm/ftr_e2e/cypress_start.ts index 7468a4473b311..67617f5a21fd8 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress_start.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress_start.ts @@ -9,7 +9,8 @@ import Url from 'url'; import cypress from 'cypress'; import { FtrProviderContext } from './ftr_provider_context'; import archives_metadata from './cypress/fixtures/es_archiver/archives_metadata'; -import { createKibanaUserRole } from '../scripts/kibana-security/create_kibana_user_role'; +import { createApmUsersAndRoles } from '../scripts/create-apm-users-and-roles/create_apm_users_and_roles'; +import { esArchiverLoad, esArchiverUnload } from './cypress/tasks/es_archiver'; export function cypressRunTests(spec?: string) { return async ({ getService }: FtrProviderContext) => { @@ -47,7 +48,7 @@ async function cypressStart( }); // Creates APM users - await createKibanaUserRole({ + await createApmUsersAndRoles({ elasticsearch: { username: config.get('servers.elasticsearch.username'), password: config.get('servers.elasticsearch.password'), @@ -58,8 +59,10 @@ async function cypressStart( }, }); - return cypressExecution({ - ...(spec !== 'undefined' ? { spec } : {}), + await esArchiverLoad('apm_8.0.0'); + + const res = await cypressExecution({ + ...(spec !== undefined ? { spec } : {}), config: { baseUrl: kibanaUrl }, env: { START_DATE: start, @@ -67,4 +70,8 @@ async function cypressStart( KIBANA_URL: kibanaUrl, }, }); + + await esArchiverUnload('apm_8.0.0'); + + return res; } diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap index 2b85d6bb3c229..6ce38b4470093 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap @@ -255,7 +255,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` - No errors were found + No errors found

diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx index 0575146a6bf5a..73eb9c72416af 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx @@ -195,7 +195,7 @@ function ErrorGroupList({ items, serviceName }: Props) { return ( ), align: 'left', - width: showWhenSmallOrGreaterThanLarge ? `${unit * 10}px` : 'auto', + width: showWhenSmallOrGreaterThanLarge ? `${unit * 11}px` : 'auto', }, { field: 'throughput', @@ -184,7 +184,7 @@ export function getServiceColumns({ /> ), align: 'left', - width: showWhenSmallOrGreaterThanLarge ? `${unit * 10}px` : 'auto', + width: showWhenSmallOrGreaterThanLarge ? `${unit * 11}px` : 'auto', }, { field: 'transactionErrorRate', diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index dc10bf413bfe9..d8a5e98be83db 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -209,6 +209,17 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { } > (props: Props) { return ( >} // EuiBasicTableColumn is stricter than ITableColumn sorting={sort} diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 2f7b1b01021e3..6201527cc971f 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -234,12 +234,9 @@ export function TransactionsTable({

- {i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableTitle', - { - defaultMessage: 'Transactions', - } - )} + {i18n.translate('xpack.apm.transactionsTable.title', { + defaultMessage: 'Transactions', + })}

@@ -250,12 +247,9 @@ export function TransactionsTable({ latencyAggregationType={latencyAggregationType} transactionType={transactionType} > - {i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableLinkText', - { - defaultMessage: 'View transactions', - } - )} + {i18n.translate('xpack.apm.transactionsTable.linkText', { + defaultMessage: 'View transactions', + })} )} @@ -265,7 +259,7 @@ export function TransactionsTable({

{i18n.translate( - 'xpack.apm.transactionCardinalityWarning.docsLink', + 'xpack.apm.transactionsTable.cardinalityWarning.docsLink', { defaultMessage: 'Learn more in the docs' } )} @@ -307,6 +301,15 @@ export function TransactionsTable({ isEmptyAndLoading={transactionGroupsTotalItems === 0 && isLoading} > +node x-pack/plugins/apm/scripts/create-apm-users-and-roles.js --username elastic --password changeme --kibana-url http://localhost:5601 --role-suffix ``` -The users will be created with the password specified in kibana.dev.yml for `elasticsearch.password` +This will create: + +**apm_read_user**: Read only user + +**apm_power_user**: Read+write user. ## Debugging Elasticsearch queries diff --git a/x-pack/plugins/apm/scripts/setup-kibana-security.js b/x-pack/plugins/apm/scripts/create-apm-users-and-roles.js similarity index 80% rename from x-pack/plugins/apm/scripts/setup-kibana-security.js rename to x-pack/plugins/apm/scripts/create-apm-users-and-roles.js index 17ab75cec723e..d64364cb38928 100644 --- a/x-pack/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles.js @@ -13,11 +13,11 @@ * The two roles will be assigned to the already existing users: `apm_read_user`, `apm_write_user`, `kibana_write_user` * * This makes it possible to use the existing cloud users locally - * Usage: node setup-kibana-security.js --role-suffix + * Usage: node create-apm-users-and-roles.js --role-suffix ******************************/ // compile typescript on the fly // eslint-disable-next-line import/no-extraneous-dependencies require('@kbn/optimizer').registerNodeAutoTranspilation(); -require('./kibana-security/setup-custom-kibana-user-role.ts'); +require('./create-apm-users-and-roles/create_apm_users_and_roles_cli.ts'); diff --git a/x-pack/plugins/apm/scripts/create-apm-users-and-roles/create_apm_users_and_roles.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/create_apm_users_and_roles.ts new file mode 100644 index 0000000000000..6b67d8d80e798 --- /dev/null +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/create_apm_users_and_roles.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AbortError, callKibana } from './helpers/call_kibana'; +import { createRole } from './helpers/create_role'; +import { powerUserRole } from './roles/power_user_role'; +import { readOnlyUserRole } from './roles/read_only_user_role'; +import { createOrUpdateUser } from './helpers/create_or_update_user'; + +export interface Elasticsearch { + username: string; + password: string; +} + +export interface Kibana { + roleSuffix: string; + hostname: string; +} + +export async function createApmUsersAndRoles({ + kibana, + elasticsearch, +}: { + kibana: Kibana; + elasticsearch: Elasticsearch; +}) { + const isSecurityEnabled = await getIsSecurityEnabled({ + elasticsearch, + kibana, + }); + if (!isSecurityEnabled) { + throw new AbortError('Security must be enabled!'); + } + + const KIBANA_READ_ROLE = `kibana_read_${kibana.roleSuffix}`; + const KIBANA_POWER_ROLE = `kibana_power_${kibana.roleSuffix}`; + + // roles definition + const roles = [ + { roleName: KIBANA_READ_ROLE, role: readOnlyUserRole }, + { roleName: KIBANA_POWER_ROLE, role: powerUserRole }, + ]; + + // create roles + await Promise.all( + roles.map(async (role) => createRole({ elasticsearch, kibana, ...role })) + ); + + // user definitions + const users = [ + { username: 'apm_read_user', roles: [KIBANA_READ_ROLE] }, + { username: 'apm_power_user', roles: [KIBANA_POWER_ROLE] }, + ]; + + // create users + await Promise.all( + users.map(async (user) => + createOrUpdateUser({ elasticsearch, kibana, user }) + ) + ); + + return users; +} + +async function getIsSecurityEnabled({ + elasticsearch, + kibana, +}: { + elasticsearch: Elasticsearch; + kibana: Kibana; +}) { + try { + await callKibana({ + elasticsearch, + kibana, + options: { + url: `/internal/security/me`, + }, + }); + return true; + } catch (err) { + return false; + } +} diff --git a/x-pack/plugins/apm/scripts/create-apm-users-and-roles/create_apm_users_and_roles_cli.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/create_apm_users_and_roles_cli.ts new file mode 100644 index 0000000000000..2b42fb3aeb625 --- /dev/null +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/create_apm_users_and_roles_cli.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable no-console */ + +import { argv } from 'yargs'; +import { AbortError, isAxiosError } from './helpers/call_kibana'; +import { createApmUsersAndRoles } from './create_apm_users_and_roles'; +import { getKibanaVersion } from './helpers/get_version'; + +async function init() { + const esUserName = (argv.username as string) || 'elastic'; + const esPassword = argv.password as string | undefined; + const kibanaBaseUrl = argv.kibanaUrl as string | undefined; + const kibanaRoleSuffix = argv.roleSuffix as string | undefined; + + if (!esPassword) { + console.error( + 'Please specify credentials for elasticsearch: `--username elastic --password abcd` ' + ); + process.exit(); + } + + if (!kibanaBaseUrl) { + console.error( + 'Please specify the url for Kibana: `--kibana-url http://localhost:5601` ' + ); + process.exit(); + } + + if ( + !kibanaBaseUrl.startsWith('https://') && + !kibanaBaseUrl.startsWith('http://') + ) { + console.error( + 'Kibana url must be prefixed with http(s):// `--kibana-url http://localhost:5601`' + ); + process.exit(); + } + + if (!kibanaRoleSuffix) { + console.error( + 'Please specify a unique suffix that will be added to your roles with `--role-suffix ` ' + ); + process.exit(); + } + + const kibana = { roleSuffix: kibanaRoleSuffix, hostname: kibanaBaseUrl }; + const elasticsearch = { username: esUserName, password: esPassword }; + + console.log({ kibana, elasticsearch }); + + const version = await getKibanaVersion({ elasticsearch, kibana }); + console.log(`Connected to Kibana ${version}`); + + const users = await createApmUsersAndRoles({ elasticsearch, kibana }); + const credentials = users + .map((u) => ` - ${u.username} / ${esPassword}`) + .join('\n'); + + console.log( + `\nYou can now login to ${kibana.hostname} with:\n${credentials}` + ); +} + +init().catch((e) => { + if (e instanceof AbortError) { + console.error(e.message); + } else if (isAxiosError(e)) { + console.error( + `${e.config.method?.toUpperCase() || 'GET'} ${e.config.url} (Code: ${ + e.response?.status + })` + ); + + if (e.response) { + console.error( + JSON.stringify( + { request: e.config, response: e.response.data }, + null, + 2 + ) + ); + } + } else { + console.error(e); + } +}); diff --git a/x-pack/plugins/apm/scripts/kibana-security/call_kibana.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/call_kibana.ts similarity index 54% rename from x-pack/plugins/apm/scripts/kibana-security/call_kibana.ts rename to x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/call_kibana.ts index 60808cf0eb927..e87c6ac7c8544 100644 --- a/x-pack/plugins/apm/scripts/kibana-security/call_kibana.ts +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/call_kibana.ts @@ -6,46 +6,51 @@ */ import axios, { AxiosRequestConfig, AxiosError } from 'axios'; import { once } from 'lodash'; -import { Elasticsearch } from './create_kibana_user_role'; +import { Elasticsearch, Kibana } from '../create_apm_users_and_roles'; export async function callKibana({ elasticsearch, - kibanaHostname, + kibana, options, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; options: AxiosRequestConfig; }): Promise { - const kibanaBasePath = await getKibanaBasePath({ kibanaHostname }); + const baseUrl = await getBaseUrl(kibana.hostname); const { username, password } = elasticsearch; const { data } = await axios.request({ ...options, - baseURL: kibanaHostname + kibanaBasePath, + baseURL: baseUrl, auth: { username, password }, headers: { 'kbn-xsrf': 'true', ...options.headers }, }); return data; } -const getKibanaBasePath = once( - async ({ kibanaHostname }: { kibanaHostname: string }) => { - try { - await axios.request({ url: kibanaHostname, maxRedirects: 0 }); - } catch (e) { - if (isAxiosError(e)) { - const location = e.response?.headers?.location; - const isBasePath = RegExp(/^\/\w{3}$/).test(location); - return isBasePath ? location : ''; - } - - throw e; +const getBaseUrl = once(async (kibanaHostname: string) => { + try { + await axios.request({ url: kibanaHostname, maxRedirects: 0 }); + } catch (e) { + if (isAxiosError(e)) { + const location = e.response?.headers?.location; + const hasBasePath = RegExp(/^\/\w{3}$/).test(location); + const basePath = hasBasePath ? location : ''; + return `${kibanaHostname}${basePath}`; } - return ''; + + throw e; } -); + return kibanaHostname; +}); export function isAxiosError(e: AxiosError | Error): e is AxiosError { return 'isAxiosError' in e; } + +export class AbortError extends Error { + constructor(message: string) { + super(message); + } +} diff --git a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/index.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/create_or_update_user.ts similarity index 58% rename from x-pack/plugins/apm/scripts/kibana-security/create_apm_users/index.ts rename to x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/create_or_update_user.ts index fea09c7383603..077e5193d6936 100644 --- a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/index.ts +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/create_or_update_user.ts @@ -8,61 +8,8 @@ /* eslint-disable no-console */ import { difference, union } from 'lodash'; -import { callKibana, isAxiosError } from '../call_kibana'; -import { Elasticsearch, Kibana } from '../create_kibana_user_role'; -import { createRole } from './create_role'; -import { powerUserRole } from './power_user_role'; -import { readOnlyUserRole } from './read_only_user_role'; - -export async function createAPMUsers({ - kibana: { roleSuffix, hostname }, - elasticsearch, -}: { - kibana: Kibana; - elasticsearch: Elasticsearch; -}) { - const KIBANA_READ_ROLE = `kibana_read_${roleSuffix}`; - const KIBANA_POWER_ROLE = `kibana_power_${roleSuffix}`; - const APM_USER_ROLE = 'apm_user'; - - // roles definition - const roles = [ - { - roleName: KIBANA_READ_ROLE, - role: readOnlyUserRole, - }, - { - roleName: KIBANA_POWER_ROLE, - role: powerUserRole, - }, - ]; - - // create roles - await Promise.all( - roles.map(async (role) => - createRole({ elasticsearch, kibanaHostname: hostname, ...role }) - ) - ); - - // users definition - const users = [ - { - username: 'apm_read_user', - roles: [APM_USER_ROLE, KIBANA_READ_ROLE], - }, - { - username: 'apm_power_user', - roles: [APM_USER_ROLE, KIBANA_POWER_ROLE], - }, - ]; - - // create users - await Promise.all( - users.map(async (user) => - createOrUpdateUser({ elasticsearch, kibanaHostname: hostname, user }) - ) - ); -} +import { Elasticsearch, Kibana } from '../create_apm_users_and_roles'; +import { callKibana, isAxiosError } from './call_kibana'; interface User { username: string; @@ -72,27 +19,27 @@ interface User { enabled?: boolean; } -async function createOrUpdateUser({ +export async function createOrUpdateUser({ elasticsearch, - kibanaHostname, + kibana, user, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; user: User; }) { const existingUser = await getUser({ elasticsearch, - kibanaHostname, + kibana, username: user.username, }); if (!existingUser) { - return createUser({ elasticsearch, kibanaHostname, newUser: user }); + return createUser({ elasticsearch, kibana, newUser: user }); } return updateUser({ elasticsearch, - kibanaHostname, + kibana, existingUser, newUser: user, }); @@ -100,16 +47,16 @@ async function createOrUpdateUser({ async function createUser({ elasticsearch, - kibanaHostname, + kibana, newUser, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; newUser: User; }) { const user = await callKibana({ elasticsearch, - kibanaHostname, + kibana, options: { method: 'POST', url: `/internal/security/users/${newUser.username}`, @@ -127,12 +74,12 @@ async function createUser({ async function updateUser({ elasticsearch, - kibanaHostname, + kibana, existingUser, newUser, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; existingUser: User; newUser: User; }) { @@ -149,7 +96,7 @@ async function updateUser({ // assign role to user await callKibana({ elasticsearch, - kibanaHostname, + kibana, options: { method: 'POST', url: `/internal/security/users/${username}`, @@ -162,17 +109,17 @@ async function updateUser({ async function getUser({ elasticsearch, - kibanaHostname, + kibana, username, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; username: string; }) { try { return await callKibana({ elasticsearch, - kibanaHostname, + kibana, options: { url: `/internal/security/users/${username}`, }, diff --git a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/create_role.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/create_role.ts similarity index 80% rename from x-pack/plugins/apm/scripts/kibana-security/create_apm_users/create_role.ts rename to x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/create_role.ts index d4814e05029a0..a4979146c5af4 100644 --- a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/create_role.ts +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/create_role.ts @@ -7,8 +7,8 @@ /* eslint-disable no-console */ import { Role } from '../../../../security/common/model'; -import { callKibana, isAxiosError } from '../call_kibana'; -import { Elasticsearch } from '../create_kibana_user_role'; +import { callKibana, isAxiosError } from './call_kibana'; +import { Elasticsearch, Kibana } from '../create_apm_users_and_roles'; type Privilege = [] | ['read'] | ['all']; export interface KibanaPrivileges { @@ -20,18 +20,18 @@ export type RoleType = Omit; export async function createRole({ elasticsearch, - kibanaHostname, + kibana, roleName, role, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; roleName: string; role: RoleType; }) { const roleFound = await getRole({ elasticsearch, - kibanaHostname, + kibana, roleName, }); if (roleFound) { @@ -41,7 +41,7 @@ export async function createRole({ await callKibana({ elasticsearch, - kibanaHostname, + kibana, options: { method: 'PUT', url: `/api/security/role/${roleName}`, @@ -52,24 +52,22 @@ export async function createRole({ }, }); - console.log( - `Created role "${roleName}" with privilege "${JSON.stringify(role.kibana)}"` - ); + console.log(`Created role "${roleName}"`); } async function getRole({ elasticsearch, - kibanaHostname, + kibana, roleName, }: { elasticsearch: Elasticsearch; - kibanaHostname: string; + kibana: Kibana; roleName: string; }): Promise { try { return await callKibana({ elasticsearch, - kibanaHostname, + kibana, options: { method: 'GET', url: `/api/security/role/${roleName}`, diff --git a/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/get_version.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/get_version.ts new file mode 100644 index 0000000000000..5b640438e8851 --- /dev/null +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/helpers/get_version.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Elasticsearch, Kibana } from '../create_apm_users_and_roles'; +import { AbortError } from './call_kibana'; +import { callKibana, isAxiosError } from './call_kibana'; + +export async function getKibanaVersion({ + elasticsearch, + kibana, +}: { + elasticsearch: Elasticsearch; + kibana: Kibana; +}) { + try { + const res: { version: { number: number } } = await callKibana({ + elasticsearch, + kibana, + options: { + method: 'GET', + url: `/api/status`, + }, + }); + return res.version.number; + } catch (e) { + if (isAxiosError(e)) { + switch (e.response?.status) { + case 401: + throw new AbortError( + `Could not access Kibana with the provided credentials. Username: "${e.config.auth?.username}". Password: "${e.config.auth?.password}"` + ); + + case 404: + throw new AbortError( + `Could not get version on ${e.config.url} (Code: 404)` + ); + + default: + throw new AbortError( + `Cannot access Kibana on ${e.config.baseURL}. Please specify Kibana with: "--kibana-url "` + ); + } + } + throw e; + } +} diff --git a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/power_user_role.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/roles/power_user_role.ts similarity index 54% rename from x-pack/plugins/apm/scripts/kibana-security/create_apm_users/power_user_role.ts rename to x-pack/plugins/apm/scripts/create-apm-users-and-roles/roles/power_user_role.ts index e9d10509f7fce..8b92096755d24 100644 --- a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/power_user_role.ts +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/roles/power_user_role.ts @@ -5,10 +5,39 @@ * 2.0. */ -import { RoleType } from './create_role'; +import { RoleType } from '../helpers/create_role'; export const powerUserRole: RoleType = { - elasticsearch: { cluster: [], indices: [], run_as: [] }, + elasticsearch: { + run_as: [], + cluster: [], + indices: [ + // apm + { + names: ['apm-*', 'logs-apm*', 'metrics-apm*', 'traces-apm*'], + privileges: ['read', 'view_index_metadata'], + }, + { + names: ['observability-annotations'], + privileges: ['read', 'write', 'view_index_metadata'], + }, + // logs + { + names: ['logs-*', 'filebeat-*', 'kibana_sample_data_logs*'], + privileges: ['read', 'view_index_metadata'], + }, + // metrics + { + names: ['metrics-*', 'metricbeat-*'], + privileges: ['read', 'view_index_metadata'], + }, + // uptime + { + names: ['heartbeat-*', 'synthetics-*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, kibana: [ { base: [], diff --git a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/read_only_user_role.ts b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/roles/read_only_user_role.ts similarity index 56% rename from x-pack/plugins/apm/scripts/kibana-security/create_apm_users/read_only_user_role.ts rename to x-pack/plugins/apm/scripts/create-apm-users-and-roles/roles/read_only_user_role.ts index 794531da73a53..fed9147b3213d 100644 --- a/x-pack/plugins/apm/scripts/kibana-security/create_apm_users/read_only_user_role.ts +++ b/x-pack/plugins/apm/scripts/create-apm-users-and-roles/roles/read_only_user_role.ts @@ -5,10 +5,41 @@ * 2.0. */ -import { RoleType } from './create_role'; +import { RoleType } from '../helpers/create_role'; export const readOnlyUserRole: RoleType = { - elasticsearch: { cluster: [], indices: [], run_as: [] }, + elasticsearch: { + run_as: [], + cluster: [], + indices: [ + // apm + { + names: [ + 'apm-*', + 'logs-apm*', + 'metrics-apm*', + 'traces-apm*', + 'observability-annotations', + ], + privileges: ['read', 'view_index_metadata'], + }, + // logs + { + names: ['logs-*', 'filebeat-*', 'kibana_sample_data_logs*'], + privileges: ['read', 'view_index_metadata'], + }, + // metrics + { + names: ['metrics-*', 'metricbeat-*'], + privileges: ['read', 'view_index_metadata'], + }, + // uptime + { + names: ['heartbeat-*', 'synthetics-*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, kibana: [ { base: [], diff --git a/x-pack/plugins/apm/scripts/ftr_e2e/cypress_run.js b/x-pack/plugins/apm/scripts/ftr_e2e/cypress_run.js index 98469008e3412..acae621553f8a 100644 --- a/x-pack/plugins/apm/scripts/ftr_e2e/cypress_run.js +++ b/x-pack/plugins/apm/scripts/ftr_e2e/cypress_run.js @@ -9,11 +9,11 @@ const { argv } = require('yargs'); const childProcess = require('child_process'); const path = require('path'); -const { spec } = argv; +const { grep } = argv; const e2eDir = path.join(__dirname, '../../ftr_e2e'); childProcess.execSync( - `node ../../../../scripts/functional_tests --config ./cypress_run.ts --grep ${spec}`, + `node ../../../../scripts/functional_tests --config ./cypress_run.ts --grep ${grep}`, { cwd: e2eDir, stdio: 'inherit' } ); diff --git a/x-pack/plugins/apm/scripts/kibana-security/create_kibana_user_role.ts b/x-pack/plugins/apm/scripts/kibana-security/create_kibana_user_role.ts deleted file mode 100644 index 9520df8133bba..0000000000000 --- a/x-pack/plugins/apm/scripts/kibana-security/create_kibana_user_role.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { callKibana, isAxiosError } from './call_kibana'; -import { createAPMUsers } from './create_apm_users'; - -/* eslint-disable no-console */ - -export interface Elasticsearch { - username: string; - password: string; -} - -export interface Kibana { - roleSuffix: string; - hostname: string; -} - -export async function createKibanaUserRole({ - kibana, - elasticsearch, -}: { - kibana: Kibana; - elasticsearch: Elasticsearch; -}) { - const version = await getKibanaVersion({ - elasticsearch, - kibanaHostname: kibana.hostname, - }); - console.log(`Connected to Kibana ${version}`); - - const isSecurityEnabled = await getIsSecurityEnabled({ - elasticsearch, - kibanaHostname: kibana.hostname, - }); - if (!isSecurityEnabled) { - throw new AbortError('Security must be enabled!'); - } - - await createAPMUsers({ kibana, elasticsearch }); -} - -async function getIsSecurityEnabled({ - elasticsearch, - kibanaHostname, -}: { - elasticsearch: Elasticsearch; - kibanaHostname: string; -}) { - try { - await callKibana({ - elasticsearch, - kibanaHostname, - options: { - url: `/internal/security/me`, - }, - }); - return true; - } catch (err) { - return false; - } -} - -async function getKibanaVersion({ - elasticsearch, - kibanaHostname, -}: { - elasticsearch: Elasticsearch; - kibanaHostname: string; -}) { - try { - const res: { version: { number: number } } = await callKibana({ - elasticsearch, - kibanaHostname, - options: { - method: 'GET', - url: `/api/status`, - }, - }); - return res.version.number; - } catch (e) { - if (isAxiosError(e)) { - switch (e.response?.status) { - case 401: - throw new AbortError( - `Could not access Kibana with the provided credentials. Username: "${e.config.auth?.username}". Password: "${e.config.auth?.password}"` - ); - - case 404: - throw new AbortError( - `Could not get version on ${e.config.url} (Code: 404)` - ); - - default: - throw new AbortError( - `Cannot access Kibana on ${e.config.baseURL}. Please specify Kibana with: "--kibana-url "` - ); - } - } - throw e; - } -} - -export class AbortError extends Error { - constructor(message: string) { - super(message); - } -} diff --git a/x-pack/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts b/x-pack/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts deleted file mode 100644 index a0264f5211379..0000000000000 --- a/x-pack/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* eslint-disable no-console */ - -import { argv } from 'yargs'; -import { isAxiosError } from './call_kibana'; -import { createKibanaUserRole, AbortError } from './create_kibana_user_role'; - -const esUserName = (argv.username as string) || 'elastic'; -const esPassword = argv.password as string | undefined; -const kibanaBaseUrl = argv.kibanaUrl as string | undefined; -const kibanaRoleSuffix = argv.roleSuffix as string | undefined; - -if (!esPassword) { - throw new Error( - 'Please specify credentials for elasticsearch: `--username elastic --password abcd` ' - ); -} - -if (!kibanaBaseUrl) { - throw new Error( - 'Please specify the url for Kibana: `--kibana-url http://localhost:5601` ' - ); -} - -if ( - !kibanaBaseUrl.startsWith('https://') && - !kibanaBaseUrl.startsWith('http://') -) { - throw new Error( - 'Kibana url must be prefixed with http(s):// `--kibana-url http://localhost:5601`' - ); -} - -if (!kibanaRoleSuffix) { - throw new Error( - 'Please specify a unique suffix that will be added to your roles with `--role-suffix ` ' - ); -} - -console.log({ - kibanaRoleSuffix, - esUserName, - esPassword, - kibanaBaseUrl, -}); - -createKibanaUserRole({ - kibana: { - roleSuffix: kibanaRoleSuffix, - hostname: kibanaBaseUrl, - }, - elasticsearch: { - username: esUserName, - password: esPassword, - }, -}).catch((e) => { - if (e instanceof AbortError) { - console.error(e.message); - } else if (isAxiosError(e)) { - console.error( - `${e.config.method?.toUpperCase() || 'GET'} ${e.config.url} (Code: ${ - e.response?.status - })` - ); - - if (e.response) { - console.error( - JSON.stringify( - { request: e.config, response: e.response.data }, - null, - 2 - ) - ); - } - } else { - console.error(e); - } -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx index 7c4c02cdc9819..c779d76af5e75 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.test.tsx @@ -29,7 +29,7 @@ describe('SourceInfoCard', () => { expect(wrapper.find(SourceIcon)).toHaveLength(1); expect(wrapper.find(EuiBadge)).toHaveLength(1); expect(wrapper.find(EuiHealth)).toHaveLength(1); - expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiText)).toHaveLength(1); expect(wrapper.find(EuiTitle)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx index d98b4f6b1e67d..e2c9cc05b04c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx @@ -64,16 +64,12 @@ export const SourceInfoCard: React.FC = ({ {isFederatedSource && ( - + - - {STATUS_LABEL} - + {STATUS_LABEL} - - {READY_TEXT} - + {READY_TEXT} )} diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 54cb0846207a3..449a1984aa53b 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -322,6 +322,7 @@ const getSavedObjectTypes = ( }, migrations: { '7.14.0': migrateInstallationToV7140, + '7.14.1': migrateInstallationToV7140, }, }, [ASSETS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx index 165a5b3dc8e31..31e65625cfdd0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_clone.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import '../../../test/global_mocks'; import { getComposableTemplate } from '../../../test/fixtures'; import { setupEnvironment } from '../helpers'; @@ -30,15 +31,6 @@ jest.mock('@elastic/eui', () => { }} /> ), - // Mocking EuiCodeEditor, which uses React Ace under the hood - EuiCodeEditor: (props: any) => ( - { - props.onChange(syntheticEvent.jsonString); - }} - /> - ), }; }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 77ce172f3e0db..67c9ed067227d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import '../../../test/global_mocks'; import { setupEnvironment } from '../helpers'; import { @@ -34,15 +35,6 @@ jest.mock('@elastic/eui', () => { }} /> ), - // Mocking EuiCodeEditor, which uses React Ace under the hood - EuiCodeEditor: (props: any) => ( - { - props.onChange(syntheticEvent.jsonString); - }} - /> - ), }; }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index efb9a8b62429b..98d361f0b9723 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import '../../../test/global_mocks'; import * as fixtures from '../../../test/fixtures'; import { setupEnvironment, BRANCH } from '../helpers'; @@ -41,15 +42,6 @@ jest.mock('@elastic/eui', () => { }} /> ), - // Mocking EuiCodeEditor, which uses React Ace under the hood - EuiCodeEditor: (props: any) => ( - { - props.onChange(syntheticEvent.jsonString); - }} - /> - ), }; }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 01aeba31770db..3a8d34c341834 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -206,7 +206,7 @@ export const formSetup = async (initTestBed: SetupFunc) => { await act(async () => { if (settings) { - find('mockCodeEditor').simulate('change', { + find('settingsEditor').simulate('change', { jsonString: settings, }); // Using mocked EuiCodeEditor } @@ -241,7 +241,7 @@ export const formSetup = async (initTestBed: SetupFunc) => { if (aliases) { await act(async () => { - find('mockCodeEditor').simulate('change', { + find('aliasesEditor').simulate('change', { jsonString: aliases, }); // Using mocked EuiCodeEditor }); @@ -337,4 +337,6 @@ export type TestSubjects = | 'templateFormContainer' | 'testingEditor' | 'versionField' + | 'aliasesEditor' + | 'settingsEditor' | 'versionField.input'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index b07cd098e540d..f3957e0cc15c9 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import '../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock'; +import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; @@ -27,15 +28,6 @@ jest.mock('@elastic/eui', () => { }} /> ), - // Mocking EuiCodeEditor, which uses React Ace under the hood - EuiCodeEditor: (props: any) => ( - { - props.onChange(syntheticEvent.jsonString); - }} - /> - ), }; }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 60602bcd6dc30..a39baf59d1f05 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import '../../../../../../test/global_mocks'; import { setupEnvironment } from './helpers'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; @@ -26,15 +27,6 @@ jest.mock('@elastic/eui', () => { }} /> ), - // Mocking EuiCodeEditor, which uses React Ace under the hood - EuiCodeEditor: (props: any) => ( - { - props.onChange(syntheticEvent.jsonString); - }} - /> - ), }; }); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts index f8ade2285016c..578a124125107 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts @@ -65,7 +65,7 @@ export const getFormActions = (testBed: TestBed) => { await act(async () => { if (settings) { - find('mockCodeEditor').simulate('change', { + find('settingsEditor').simulate('change', { jsonString: JSON.stringify(settings), }); // Using mocked EuiCodeEditor } @@ -118,7 +118,7 @@ export const getFormActions = (testBed: TestBed) => { await act(async () => { if (aliases) { - find('mockCodeEditor').simulate('change', { + find('aliasesEditor').simulate('change', { jsonString: JSON.stringify(aliases), }); // Using mocked EuiCodeEditor } @@ -161,4 +161,6 @@ export type ComponentTemplateFormTestSubjects = | 'stepReview.summaryTab' | 'stepReview.requestTab' | 'versionField' + | 'aliasesEditor' + | 'settingsEditor' | 'versionField.input'; diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx index 1099ecaa7949f..2d7be72056e18 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx @@ -15,12 +15,11 @@ import { EuiSpacer, EuiFormRow, EuiText, - EuiCodeEditor, EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Forms } from '../../../../../shared_imports'; +import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx index e6bec46805169..359e1091c1303 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx @@ -15,12 +15,11 @@ import { EuiSpacer, EuiFormRow, EuiText, - EuiCodeEditor, EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Forms } from '../../../../../shared_imports'; +import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index fa27b22e502fa..275f8af818caf 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -22,6 +22,7 @@ export { PageError, Error, SectionLoading, + EuiCodeEditor, } from '../../../../src/plugins/es_ui_shared/public'; export { diff --git a/x-pack/plugins/index_management/test/global_mocks.tsx b/x-pack/plugins/index_management/test/global_mocks.tsx new file mode 100644 index 0000000000000..342d74bce853e --- /dev/null +++ b/x-pack/plugins/index_management/test/global_mocks.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +// NOTE: Import this file for its side-effects. You must import it before the code that it mocks +// is imported. Typically this means just importing above your other imports. +// See https://jestjs.io/docs/manual-mocks for more info. + +jest.mock('../../../../src/plugins/es_ui_shared/public', () => { + const original = jest.requireActual('../../../../src/plugins/es_ui_shared/public'); + + return { + ...original, + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), + }; +}); diff --git a/x-pack/plugins/ml/common/types/kibana.ts b/x-pack/plugins/ml/common/types/kibana.ts index cbdc09fa01be3..7783a02c2dd37 100644 --- a/x-pack/plugins/ml/common/types/kibana.ts +++ b/x-pack/plugins/ml/common/types/kibana.ts @@ -7,8 +7,9 @@ // custom edits or fixes for default kibana types which are incomplete -import { SimpleSavedObject } from 'kibana/public'; -import { IndexPatternAttributes } from 'src/plugins/data/common'; +import type { SimpleSavedObject } from 'kibana/public'; +import type { IndexPatternAttributes } from 'src/plugins/data/common'; +import type { FieldFormatsRegistry } from '../../../../../src/plugins/field_formats/common'; export type IndexPatternTitle = string; @@ -26,3 +27,5 @@ export function isSavedSearchSavedObject( ): ss is SavedSearchSavedObject { return ss !== null; } + +export type FieldFormatsRegistryProvider = () => Promise; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 1196247fe4629..310ac5d65c986 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -17,7 +17,8 @@ "uiActions", "kibanaLegacy", "discover", - "triggersActionsUi" + "triggersActionsUi", + "fieldFormats" ], "optionalPlugins": [ "alerting", diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts index a5f433bcc3752..68a06919d03a3 100644 --- a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts @@ -87,7 +87,7 @@ export function registerJobsHealthAlertingRule( defaultActionMessage: i18n.translate( 'xpack.ml.alertTypes.jobsHealthAlertingRule.defaultActionMessage', { - defaultMessage: `Anomaly detection jobs health check result: + defaultMessage: `[\\{\\{rule.name\\}\\}] Anomaly detection jobs health check result: \\{\\{context.message\\}\\} \\{\\{#context.results\\}\\} Job ID: \\{\\{job_id\\}\\} diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts index ffaa26fc949ee..7192b9a919379 100644 --- a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts @@ -14,6 +14,7 @@ import { AnnotationService } from '../../models/annotation_service/annotation'; import { JobsHealthExecutorOptions } from './register_jobs_monitoring_rule_type'; import { JobAuditMessagesService } from '../../models/job_audit_messages/job_audit_messages'; import { DeepPartial } from '../../../common/types/common'; +import { FieldFormatsRegistryProvider } from '../../../common/types/kibana'; const MOCK_DATE_NOW = 1487076708000; @@ -148,8 +149,8 @@ describe('JobsHealthService', () => { } as unknown) as jest.Mocked; const jobAuditMessagesService = ({ - getJobsErrors: jest.fn().mockImplementation((jobIds: string) => { - return Promise.resolve({}); + getJobsErrorMessages: jest.fn().mockImplementation((jobIds: string) => { + return Promise.resolve([]); }), } as unknown) as jest.Mocked; @@ -159,11 +160,24 @@ describe('JobsHealthService', () => { debug: jest.fn(), } as unknown) as jest.Mocked; + const getFieldsFormatRegistry = jest.fn().mockImplementation(() => { + return Promise.resolve({ + deserialize: jest.fn().mockImplementation(() => { + return { + convert: jest.fn().mockImplementation((v) => { + return new Date(v).toUTCString(); + }), + }; + }), + }); + }) as jest.Mocked; + const jobHealthService: JobsHealthService = jobsHealthServiceProvider( mlClient, datafeedsService, annotationService, jobAuditMessagesService, + getFieldsFormatRegistry, logger ); @@ -275,11 +289,11 @@ describe('JobsHealthService', () => { job_id: 'test_job_01', annotation: 'Datafeed has missed 11 documents due to ingest latency, latest bucket with missing data is [2021-07-30T13:50:00.000Z]. Consider increasing query_delay', - end_timestamp: 1627653300000, + end_timestamp: 'Fri, 30 Jul 2021 13:55:00 GMT', missed_docs_count: 11, }, ], - message: '1 job is suffering from delayed data.', + message: 'Job test_job_01 is suffering from delayed data.', }, }, ]); @@ -333,7 +347,7 @@ describe('JobsHealthService', () => { datafeed_state: 'stopped', }, ], - message: 'Datafeed is not started for the following jobs:', + message: 'Datafeed is not started for job test_job_02', }, }, { @@ -342,12 +356,12 @@ describe('JobsHealthService', () => { results: [ { job_id: 'test_job_01', - log_time: 1626935914540, + log_time: 'Thu, 22 Jul 2021 06:38:34 GMT', memory_status: 'hard_limit', }, ], message: - '1 job reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.', + 'Job test_job_01 reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.', }, }, { @@ -358,18 +372,18 @@ describe('JobsHealthService', () => { job_id: 'test_job_01', annotation: 'Datafeed has missed 11 documents due to ingest latency, latest bucket with missing data is [2021-07-30T13:50:00.000Z]. Consider increasing query_delay', - end_timestamp: 1627653300000, + end_timestamp: 'Fri, 30 Jul 2021 13:55:00 GMT', missed_docs_count: 11, }, { job_id: 'test_job_02', annotation: 'Datafeed has missed 8 documents due to ingest latency, latest bucket with missing data is [2021-07-30T13:50:00.000Z]. Consider increasing query_delay', - end_timestamp: 1627653300000, + end_timestamp: 'Fri, 30 Jul 2021 13:55:00 GMT', missed_docs_count: 8, }, ], - message: '2 jobs are suffering from delayed data.', + message: 'Jobs test_job_01, test_job_02 are suffering from delayed data.', }, }, ]); diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts index bcae57e558573..ca63031f02e27 100644 --- a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { memoize, keyBy } from 'lodash'; -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { groupBy, keyBy, memoize } from 'lodash'; +import { KibanaRequest, Logger, SavedObjectsClientContract } from 'kibana/server'; import { i18n } from '@kbn/i18n'; -import { Logger } from 'kibana/server'; import { MlJob } from '@elastic/elasticsearch/api/types'; import { MlClient } from '../ml_client'; import { JobSelection } from '../../routes/schemas/alerting_schema'; @@ -19,6 +18,7 @@ import { GetGuards } from '../../shared_services/shared_services'; import { AnomalyDetectionJobsHealthAlertContext, DelayedDataResponse, + JobsErrorsResponse, JobsHealthExecutorOptions, MmlTestResponse, NotStartedDatafeedResponse, @@ -35,6 +35,7 @@ import { jobAuditMessagesProvider, JobAuditMessagesService, } from '../../models/job_audit_messages/job_audit_messages'; +import type { FieldFormatsRegistryProvider } from '../../../common/types/kibana'; interface TestResult { name: string; @@ -48,8 +49,18 @@ export function jobsHealthServiceProvider( datafeedsService: DatafeedsService, annotationService: AnnotationService, jobAuditMessagesService: JobAuditMessagesService, + getFieldsFormatRegistry: FieldFormatsRegistryProvider, logger: Logger ) { + /** + * Provides a callback for date formatting based on the Kibana settings. + */ + const getDateFormatter = memoize(async () => { + const fieldFormatsRegistry = await getFieldsFormatRegistry(); + const dateFormatter = fieldFormatsRegistry.deserialize({ id: 'date' }); + return dateFormatter.convert.bind(dateFormatter); + }); + /** * Extracts result list of jobs based on included and excluded selection of jobs and groups. * @param includeJobs @@ -121,6 +132,17 @@ export function jobsHealthServiceProvider( async (jobIds: string[]) => (await mlClient.getJobStats({ job_id: jobIds.join(',') })).body.jobs ); + /** Gets values for translation string */ + const getJobsAlertingMessageValues = >( + results: T + ) => { + const jobIds = (results || []).filter(isDefined).map((v) => v.job_id); + return { + count: jobIds.length, + jobsString: jobIds.join(', '), + }; + }; + return { /** * Gets not started datafeeds for opened jobs. @@ -164,13 +186,15 @@ export function jobsHealthServiceProvider( async getMmlReport(jobIds: string[]): Promise { const jobsStats = await getJobStats(jobIds); + const dateFormatter = await getDateFormatter(); + return jobsStats .filter((j) => j.state === 'opened' && j.model_size_stats.memory_status !== 'ok') .map(({ job_id: jobId, model_size_stats: modelSizeStats }) => { return { job_id: jobId, memory_status: modelSizeStats.memory_status, - log_time: modelSizeStats.log_time, + log_time: dateFormatter(modelSizeStats.log_time), model_bytes: modelSizeStats.model_bytes, model_bytes_memory_limit: modelSizeStats.model_bytes_memory_limit, peak_model_bytes: modelSizeStats.peak_model_bytes, @@ -203,13 +227,15 @@ export function jobsHealthServiceProvider( const defaultLookbackInterval = resolveLookbackInterval(resultJobs, datafeeds!); const earliestMs = getDelayedDataLookbackTimestamp(timeInterval, defaultLookbackInterval); - const annotations: DelayedDataResponse[] = ( + const getFormattedDate = await getDateFormatter(); + + return ( await annotationService.getDelayedDataAnnotations({ jobIds: resultJobIds, earliestMs, }) ) - .map((v) => { + .map((v) => { const match = v.annotation.match(/Datafeed has missed (\d+)\s/); const missedDocsCount = match ? parseInt(match[1], 10) : 0; return { @@ -235,9 +261,13 @@ export function jobsHealthServiceProvider( v.end_timestamp > getDelayedDataLookbackTimestamp(timeInterval, jobLookbackInterval); return isDocCountExceededThreshold && isEndTimestampWithinRange; + }) + .map((v) => { + return { + ...v, + end_timestamp: getFormattedDate(v.end_timestamp), + }; }); - - return annotations; }, /** * Retrieves a list of the latest errors per jobs. @@ -245,8 +275,25 @@ export function jobsHealthServiceProvider( * @param previousStartedAt Time of the previous rule execution. As we intend to notify * about an error only once, limit the scope of the errors search. */ - async getErrorsReport(jobIds: string[], previousStartedAt: Date) { - return await jobAuditMessagesService.getJobsErrors(jobIds, previousStartedAt.getTime()); + async getErrorsReport( + jobIds: string[], + previousStartedAt: Date + ): Promise { + const getFormattedDate = await getDateFormatter(); + + return ( + await jobAuditMessagesService.getJobsErrorMessages(jobIds, previousStartedAt.getTime()) + ).map((v) => { + return { + ...v, + errors: v.errors.map((e) => { + return { + ...e, + timestamp: getFormattedDate(e.timestamp), + }; + }), + }; + }); }, /** * Retrieves report grouped by test. @@ -275,6 +322,7 @@ export function jobsHealthServiceProvider( if (config.datafeed.enabled) { const response = await this.getNotStartedDatafeeds(jobIds); if (response && response.length > 0) { + const { count, jobsString } = getJobsAlertingMessageValues(response); results.push({ name: HEALTH_CHECK_NAMES.datafeed.name, context: { @@ -282,7 +330,9 @@ export function jobsHealthServiceProvider( message: i18n.translate( 'xpack.ml.alertTypes.jobsHealthAlertingRule.datafeedStateMessage', { - defaultMessage: 'Datafeed is not started for the following jobs:', + defaultMessage: + 'Datafeed is not started for {count, plural, one {job} other {jobs}} {jobsString}', + values: { count, jobsString }, } ), }, @@ -293,32 +343,54 @@ export function jobsHealthServiceProvider( if (config.mml.enabled) { const response = await this.getMmlReport(jobIds); if (response && response.length > 0) { - const hardLimitJobsCount = response.reduce((acc, curr) => { - return acc + (curr.memory_status === 'hard_limit' ? 1 : 0); - }, 0); + const { hard_limit: hardLimitJobs, soft_limit: softLimitJobs } = groupBy( + response, + 'memory_status' + ); + + const { + count: hardLimitCount, + jobsString: hardLimitJobsString, + } = getJobsAlertingMessageValues(hardLimitJobs); + const { + count: softLimitCount, + jobsString: softLimitJobsString, + } = getJobsAlertingMessageValues(softLimitJobs); + + let message = ''; + + if (hardLimitCount > 0) { + message = i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.mmlMessage', { + defaultMessage: `{count, plural, one {Job} other {Jobs}} {jobsString} reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.`, + values: { + count: hardLimitCount, + jobsString: hardLimitJobsString, + }, + }); + } + + if (softLimitCount > 0) { + if (message.length > 0) { + message += '\n'; + } + message += i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.mmlSoftLimitMessage', + { + defaultMessage: + '{count, plural, one {Job} other {Jobs}} {jobsString} reached the soft model memory limit. Assign the job more memory or edit the datafeed filter to limit scope of analysis.', + values: { + count: softLimitCount, + jobsString: softLimitJobsString, + }, + } + ); + } results.push({ name: HEALTH_CHECK_NAMES.mml.name, context: { results: response, - message: - hardLimitJobsCount > 0 - ? i18n.translate( - 'xpack.ml.alertTypes.jobsHealthAlertingRule.mmlHardLimitMessage', - { - defaultMessage: - '{jobsCount, plural, one {# job} other {# jobs}} reached the hard model memory limit. Assign the job more memory and restore from a snapshot from prior to reaching the hard limit.', - values: { jobsCount: hardLimitJobsCount }, - } - ) - : i18n.translate( - 'xpack.ml.alertTypes.jobsHealthAlertingRule.mmlSoftLimitMessage', - { - defaultMessage: - '{jobsCount, plural, one {# job} other {# jobs}} reached the soft model memory limit. Assign the job more memory or edit the datafeed filter to limit scope of analysis.', - values: { jobsCount: response.length }, - } - ), + message, }, }); } @@ -331,6 +403,8 @@ export function jobsHealthServiceProvider( config.delayedData.docsCount ); + const { count, jobsString } = getJobsAlertingMessageValues(response); + if (response.length > 0) { results.push({ name: HEALTH_CHECK_NAMES.delayedData.name, @@ -340,8 +414,8 @@ export function jobsHealthServiceProvider( 'xpack.ml.alertTypes.jobsHealthAlertingRule.delayedDataMessage', { defaultMessage: - '{jobsCount, plural, one {# job is} other {# jobs are}} suffering from delayed data.', - values: { jobsCount: response.length }, + '{count, plural, one {Job} other {Jobs}} {jobsString} {count, plural, one {is} other {are}} suffering from delayed data.', + values: { count, jobsString }, } ), }, @@ -352,6 +426,7 @@ export function jobsHealthServiceProvider( if (config.errorMessages.enabled && previousStartedAt) { const response = await this.getErrorsReport(jobIds, previousStartedAt); if (response.length > 0) { + const { count, jobsString } = getJobsAlertingMessageValues(response); results.push({ name: HEALTH_CHECK_NAMES.errorMessages.name, context: { @@ -360,8 +435,8 @@ export function jobsHealthServiceProvider( 'xpack.ml.alertTypes.jobsHealthAlertingRule.errorMessagesMessage', { defaultMessage: - '{jobsCount, plural, one {# job contains} other {# jobs contain}} errors in the messages.', - values: { jobsCount: response.length }, + '{count, plural, one {Job} other {Jobs}} {jobsString} {count, plural, one {contains} other {contain}} errors in the messages.', + values: { count, jobsString }, } ), }, @@ -390,12 +465,13 @@ export function getJobsHealthServiceProvider(getGuards: GetGuards) { return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(({ mlClient, scopedClient }) => + .ok(({ mlClient, scopedClient, getFieldsFormatRegistry }) => jobsHealthServiceProvider( mlClient, datafeedsProvider(scopedClient, mlClient), annotationServiceProvider(scopedClient), jobAuditMessagesProvider(scopedClient, mlClient), + getFieldsFormatRegistry, logger ).getTestsResults(...args) ); diff --git a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts index c49c169d3bd21..4844bf1a94707 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts @@ -22,8 +22,8 @@ import { AlertInstanceState, AlertTypeState, } from '../../../../alerting/common'; -import { JobsErrorsResponse } from '../../models/job_audit_messages/job_audit_messages'; -import { AlertExecutorOptions } from '../../../../alerting/server'; +import type { AlertExecutorOptions } from '../../../../alerting/server'; +import type { JobMessage } from '../../../common/types/audit_message'; type ModelSizeStats = MlJobStats['model_size_stats']; @@ -51,14 +51,19 @@ export interface DelayedDataResponse { /** Number of missed documents */ missed_docs_count: number; /** Timestamp of the latest finalized bucket with missing docs */ - end_timestamp: number; + end_timestamp: string; +} + +export interface JobsErrorsResponse { + job_id: string; + errors: Array & { timestamp: string }>; } export type AnomalyDetectionJobHealthResult = | MmlTestResponse | NotStartedDatafeedResponse | DelayedDataResponse - | JobsErrorsResponse[number]; + | JobsErrorsResponse; export type AnomalyDetectionJobsHealthAlertContext = { results: AnomalyDetectionJobHealthResult[]; @@ -143,7 +148,7 @@ export function registerJobsMonitoringRuleType({ const executionResult = await getTestsResults(options); if (executionResult.length > 0) { - logger.info( + logger.debug( `"${name}" rule is scheduling actions for tests: ${executionResult .map((v) => v.name) .join(', ')}` diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts index fcda1a2a3ea73..69f5c8b36f10c 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.ts @@ -411,7 +411,10 @@ export function jobAuditMessagesProvider( * Retrieve list of errors per job. * @param jobIds */ - async function getJobsErrors(jobIds: string[], earliestMs?: number): Promise { + async function getJobsErrorMessages( + jobIds: string[], + earliestMs?: number + ): Promise { const { body } = await asInternalUser.search({ index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, @@ -471,6 +474,6 @@ export function jobAuditMessagesProvider( getJobAuditMessages, getAuditMessagesSummary, clearJobAuditMessages, - getJobsErrors, + getJobsErrorMessages, }; } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 35f66e86b955a..4dea3cc072ca5 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -17,6 +17,7 @@ import { IClusterClient, SavedObjectsServiceStart, SharedGlobalConfig, + UiSettingsServiceStart, } from 'kibana/server'; import type { SecurityPluginSetup } from '../../security/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -60,6 +61,7 @@ import { registerMlAlerts } from './lib/alerts/register_ml_alerts'; import { ML_ALERT_TYPES } from '../common/constants/alerts'; import { alertingRoutes } from './routes/alerting'; import { registerCollector } from './usage'; +import { FieldFormatsStart } from '../../../../src/plugins/field_formats/server'; export type MlPluginSetup = SharedServices; export type MlPluginStart = void; @@ -70,6 +72,8 @@ export class MlServerPlugin private mlLicense: MlLicense; private capabilities: CapabilitiesStart | null = null; private clusterClient: IClusterClient | null = null; + private fieldsFormat: FieldFormatsStart | null = null; + private uiSettings: UiSettingsServiceStart | null = null; private savedObjectsStart: SavedObjectsServiceStart | null = null; private spacesPlugin: SpacesPluginSetup | undefined; private security: SecurityPluginSetup | undefined; @@ -204,6 +208,8 @@ export class MlServerPlugin resolveMlCapabilities, () => this.clusterClient, () => getInternalSavedObjectsClient(), + () => this.uiSettings, + () => this.fieldsFormat, () => this.isMlReady ); @@ -223,7 +229,9 @@ export class MlServerPlugin return sharedServicesProviders; } - public start(coreStart: CoreStart): MlPluginStart { + public start(coreStart: CoreStart, plugins: PluginsStart): MlPluginStart { + this.uiSettings = coreStart.uiSettings; + this.fieldsFormat = plugins.fieldFormats; this.capabilities = coreStart.capabilities; this.clusterClient = coreStart.elasticsearch.client; this.savedObjectsStart = coreStart.savedObjects; diff --git a/x-pack/plugins/ml/server/shared_services/errors.test.ts b/x-pack/plugins/ml/server/shared_services/errors.test.ts new file mode 100644 index 0000000000000..727012595dff3 --- /dev/null +++ b/x-pack/plugins/ml/server/shared_services/errors.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getCustomErrorClass, + MLClusterClientUninitialized, + MLUISettingsClientUninitialized, + MLFieldFormatRegistryUninitialized, +} from './errors'; + +describe('Custom errors', () => { + test('creates a custom error instance', () => { + const MLCustomError = getCustomErrorClass('MLCustomError'); + const errorInstance = new MLCustomError('farequote is not defined'); + expect(errorInstance.message).toBe('farequote is not defined'); + expect(errorInstance.name).toBe('MLCustomError'); + expect(errorInstance).toBeInstanceOf(MLCustomError); + // make sure that custom class extends Error + expect(errorInstance).toBeInstanceOf(Error); + }); + + test('MLClusterClientUninitialized', () => { + const errorInstance = new MLClusterClientUninitialized('cluster client is not initialized'); + expect(errorInstance.message).toBe('cluster client is not initialized'); + expect(errorInstance.name).toBe('MLClusterClientUninitialized'); + expect(errorInstance).toBeInstanceOf(MLClusterClientUninitialized); + }); + + test('MLUISettingsClientUninitialized', () => { + const errorInstance = new MLUISettingsClientUninitialized('cluster client is not initialized'); + expect(errorInstance.message).toBe('cluster client is not initialized'); + expect(errorInstance.name).toBe('MLUISettingsClientUninitialized'); + expect(errorInstance).toBeInstanceOf(MLUISettingsClientUninitialized); + }); + + test('MLFieldFormatRegistryUninitialized', () => { + const errorInstance = new MLFieldFormatRegistryUninitialized( + 'cluster client is not initialized' + ); + expect(errorInstance.message).toBe('cluster client is not initialized'); + expect(errorInstance.name).toBe('MLFieldFormatRegistryUninitialized'); + expect(errorInstance).toBeInstanceOf(MLFieldFormatRegistryUninitialized); + }); +}); diff --git a/x-pack/plugins/ml/server/shared_services/errors.ts b/x-pack/plugins/ml/server/shared_services/errors.ts index 4b8e6625c5aef..39c629ad50f5f 100644 --- a/x-pack/plugins/ml/server/shared_services/errors.ts +++ b/x-pack/plugins/ml/server/shared_services/errors.ts @@ -5,9 +5,26 @@ * 2.0. */ -export class MLClusterClientUninitialized extends Error { - constructor(message?: string) { - super(message); - Object.setPrototypeOf(this, new.target.prototype); - } -} +export const getCustomErrorClass = (className: string) => { + const CustomError = class extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + // Override the error instance name + Object.defineProperty(this, 'name', { value: className }); + } + }; + // set class name dynamically + Object.defineProperty(CustomError, 'name', { value: className }); + return CustomError; +}; + +export const MLClusterClientUninitialized = getCustomErrorClass('MLClusterClientUninitialized'); + +export const MLUISettingsClientUninitialized = getCustomErrorClass( + 'MLUISettingsClientUninitialized' +); + +export const MLFieldFormatRegistryUninitialized = getCustomErrorClass( + 'MLFieldFormatRegistryUninitialized' +); diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index 3766a48b0537d..5c8bbffe10aed 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { IClusterClient, IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; +import { + IClusterClient, + IScopedClusterClient, + SavedObjectsClientContract, + UiSettingsServiceStart, +} from 'kibana/server'; import { SpacesPluginStart } from '../../../spaces/server'; import { KibanaRequest } from '../../.././../../src/core/server'; import { MlLicense } from '../../common/license'; @@ -23,7 +28,11 @@ import { } from './providers/anomaly_detectors'; import { ResolveMlCapabilities, MlCapabilitiesKey } from '../../common/types/capabilities'; import { hasMlCapabilitiesProvider, HasMlCapabilities } from '../lib/capabilities'; -import { MLClusterClientUninitialized } from './errors'; +import { + MLClusterClientUninitialized, + MLFieldFormatRegistryUninitialized, + MLUISettingsClientUninitialized, +} from './errors'; import { MlClient, getMlClient } from '../lib/ml_client'; import { jobSavedObjectServiceFactory, JobSavedObjectService } from '../saved_objects'; import { @@ -34,6 +43,8 @@ import { getJobsHealthServiceProvider, JobsHealthServiceProvider, } from '../lib/alerts/jobs_health_service'; +import type { FieldFormatsStart } from '../../../../../src/plugins/field_formats/server'; +import type { FieldFormatsRegistryProvider } from '../../common/types/kibana'; export type SharedServices = JobServiceProvider & AnomalyDetectorsProvider & @@ -64,6 +75,7 @@ interface OkParams { scopedClient: IScopedClusterClient; mlClient: MlClient; jobSavedObjectService: JobSavedObjectService; + getFieldsFormatRegistry: FieldFormatsRegistryProvider; } type OkCallback = (okParams: OkParams) => any; @@ -76,6 +88,8 @@ export function createSharedServices( resolveMlCapabilities: ResolveMlCapabilities, getClusterClient: () => IClusterClient | null, getInternalSavedObjectsClient: () => SavedObjectsClientContract | null, + getUiSettings: () => UiSettingsServiceStart | null, + getFieldsFormat: () => FieldFormatsStart | null, isMlReady: () => Promise ): { sharedServicesProviders: SharedServices; @@ -97,12 +111,18 @@ export function createSharedServices( internalSavedObjectsClient, authorization, getSpaces !== undefined, - isMlReady + isMlReady, + getUiSettings, + getFieldsFormat ); - const { hasMlCapabilities, scopedClient, mlClient, jobSavedObjectService } = getRequestItems( - request - ); + const { + hasMlCapabilities, + scopedClient, + mlClient, + jobSavedObjectService, + getFieldsFormatRegistry, + } = getRequestItems(request); const asyncGuards: Array> = []; const guards: Guards = { @@ -120,7 +140,7 @@ export function createSharedServices( }, async ok(callback: OkCallback) { await Promise.all(asyncGuards); - return callback({ scopedClient, mlClient, jobSavedObjectService }); + return callback({ scopedClient, mlClient, jobSavedObjectService, getFieldsFormatRegistry }); }, }; return guards; @@ -154,7 +174,9 @@ function getRequestItemsProvider( internalSavedObjectsClient: SavedObjectsClientContract, authorization: SecurityPluginSetup['authz'] | undefined, spaceEnabled: boolean, - isMlReady: () => Promise + isMlReady: () => Promise, + getUiSettings: () => UiSettingsServiceStart | null, + getFieldsFormat: () => FieldFormatsStart | null ) { return (request: KibanaRequest) => { const getHasMlCapabilities = hasMlCapabilitiesProvider(resolveMlCapabilities); @@ -177,6 +199,28 @@ function getRequestItemsProvider( throw new MLClusterClientUninitialized(`ML's cluster client has not been initialized`); } + const uiSettingsClient = getUiSettings()?.asScopedToClient(savedObjectsClient); + if (!uiSettingsClient) { + throw new MLUISettingsClientUninitialized(`ML's UI settings client has not been initialized`); + } + + const getFieldsFormatRegistry = async () => { + let fieldFormatRegistry; + try { + fieldFormatRegistry = await getFieldsFormat()!.fieldFormatServiceFactory(uiSettingsClient!); + } catch (e) { + // throw an custom error during the fieldFormatRegistry check + } + + if (!fieldFormatRegistry) { + throw new MLFieldFormatRegistryUninitialized( + `ML's field format registry has not been initialized` + ); + } + + return fieldFormatRegistry; + }; + if (request instanceof KibanaRequest) { hasMlCapabilities = getHasMlCapabilities(request); scopedClient = clusterClient.asScoped(request); @@ -190,6 +234,12 @@ function getRequestItemsProvider( }; mlClient = getMlClient(scopedClient, jobSavedObjectService); } - return { hasMlCapabilities, scopedClient, mlClient, jobSavedObjectService }; + return { + hasMlCapabilities, + scopedClient, + mlClient, + jobSavedObjectService, + getFieldsFormatRegistry, + }; }; } diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index b04b8d8601772..da83b03766af4 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -23,6 +23,10 @@ import type { PluginSetup as DataPluginSetup, PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; +import type { + FieldFormatsSetup, + FieldFormatsStart, +} from '../../../../src/plugins/field_formats/server'; export interface LicenseCheckResult { isAvailable: boolean; @@ -47,6 +51,7 @@ export interface SavedObjectsRouteDeps { export interface PluginsSetup { cloud: CloudSetup; data: DataPluginSetup; + fieldFormats: FieldFormatsSetup; features: FeaturesPluginSetup; home: HomeServerPluginSetup; licensing: LicensingPluginSetup; @@ -59,6 +64,7 @@ export interface PluginsSetup { export interface PluginsStart { data: DataPluginStart; + fieldFormats: FieldFormatsStart; spaces?: SpacesPluginStart; } diff --git a/x-pack/plugins/reporting/common/index.ts b/x-pack/plugins/reporting/common/index.ts index 00bba85152656..a45ef4cf2919d 100644 --- a/x-pack/plugins/reporting/common/index.ts +++ b/x-pack/plugins/reporting/common/index.ts @@ -5,15 +5,6 @@ * 2.0. */ -import { LayoutSelectorDictionary } from './types'; - export * as constants from './constants'; export { CancellationToken } from './cancellation_token'; export { Poller } from './poller'; - -export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({ - screenshot: '[data-shared-items-container]', - renderComplete: '[data-shared-item]', - itemsCountAttribute: 'data-shared-items-count', - timefilterDurationAttribute: 'data-shared-timefilter-duration', -}); diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 8833453efecec..745bc11a8c855 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -16,13 +16,6 @@ export interface PageSizeParams { subheadingHeight: number; } -export interface LayoutSelectorDictionary { - screenshot: string; - renderComplete: string; - itemsCountAttribute: string; - timefilterDurationAttribute: string; -} - export interface PdfImageSize { width: number; height?: number; @@ -36,7 +29,6 @@ export interface Size { export interface LayoutParams { id: string; dimensions?: Size; - selectors?: LayoutSelectorDictionary; } export interface ReportDocumentHead { diff --git a/x-pack/plugins/reporting/public/index.ts b/x-pack/plugins/reporting/public/index.ts index 6acdd8fb048e8..2df236e6e3079 100644 --- a/x-pack/plugins/reporting/public/index.ts +++ b/x-pack/plugins/reporting/public/index.ts @@ -6,20 +6,18 @@ */ import { PluginInitializerContext } from 'src/core/public'; -import { getDefaultLayoutSelectors } from '../common'; import { ReportingAPIClient } from './lib/reporting_api_client'; import { ReportingPublicPlugin } from './plugin'; import { getSharedComponents } from './shared'; export interface ReportingSetup { - getDefaultLayoutSelectors: typeof getDefaultLayoutSelectors; usesUiCapabilities: () => boolean; components: ReturnType; } export type ReportingStart = ReportingSetup; -export { constants, getDefaultLayoutSelectors } from '../common'; +export { constants } from '../common'; export { ReportingAPIClient, ReportingPublicPlugin as Plugin }; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/x-pack/plugins/reporting/public/mocks.ts b/x-pack/plugins/reporting/public/mocks.ts index 41b4d26dc5a59..83806455cbfd4 100644 --- a/x-pack/plugins/reporting/public/mocks.ts +++ b/x-pack/plugins/reporting/public/mocks.ts @@ -8,7 +8,6 @@ import { coreMock } from 'src/core/public/mocks'; import { ReportingAPIClient } from './lib/reporting_api_client'; import { ReportingSetup } from '.'; -import { getDefaultLayoutSelectors } from '../common'; import { getSharedComponents } from './shared'; type Setup = jest.Mocked; @@ -17,7 +16,6 @@ const createSetupContract = (): Setup => { const coreSetup = coreMock.createSetup(); const apiClient = new ReportingAPIClient(coreSetup.http, coreSetup.uiSettings, '7.15.0'); return { - getDefaultLayoutSelectors: jest.fn().mockImplementation(getDefaultLayoutSelectors), usesUiCapabilities: jest.fn().mockImplementation(() => true), components: getSharedComponents(coreSetup, apiClient), }; diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index 2529681a6901f..b010acd45c296 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -25,7 +25,7 @@ import { } from '../../../../src/plugins/home/public'; import { ManagementSetup, ManagementStart } from '../../../../src/plugins/management/public'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/public'; -import { constants, getDefaultLayoutSelectors } from '../common'; +import { constants } from '../common'; import { durationToNumber } from '../common/schema_utils'; import { JobId, JobSummarySet } from '../common/types'; import { ReportingSetup, ReportingStart } from './'; @@ -121,7 +121,6 @@ export class ReportingPublicPlugin private getContract(core?: CoreSetup) { if (core) { this.contract = { - getDefaultLayoutSelectors, usesUiCapabilities: () => this.config.roles?.enabled === false, components: getSharedComponents(core, this.getApiClient(core.http, core.uiSettings)), }; diff --git a/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap index 6f0fc18e90adc..969963fd3ca77 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap +++ b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap @@ -349,7 +349,7 @@ exports[`ScreenCapturePanelContent properly renders a view with "canvas" layout { dimensions = { height, width }; } - let selectors = outerLayout?.selectors; - if (!selectors) { - selectors = getDefaultLayoutSelectors(); - } - if (this.state.usePrintLayout) { - return { id: 'print', dimensions, selectors }; + return { id: 'print', dimensions }; } if (this.state.useCanvasLayout) { - return { id: 'canvas', dimensions, selectors }; + return { id: 'canvas', dimensions }; } - return { id: 'preserve_layout', dimensions, selectors }; + return { id: 'preserve_layout', dimensions }; }; private getJobParams = () => { diff --git a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts index 1cc1b15dbdfba..b6f9c3fba8ea9 100644 --- a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -32,7 +32,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { if (!layoutParams || !layoutParams.dimensions) { throw new Error(`LayoutParams.Dimensions is undefined.`); } - const layout = new PreserveLayout(layoutParams.dimensions, layoutParams.selectors); + const layout = new PreserveLayout(layoutParams.dimensions); if (apmLayout) apmLayout.end(); const apmScreenshots = apmTrans?.startSpan('screenshots_pipeline', 'setup'); diff --git a/x-pack/plugins/reporting/server/lib/layouts/index.ts b/x-pack/plugins/reporting/server/lib/layouts/index.ts index e7ded2c003e7a..9729508f955c7 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/index.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/index.ts @@ -6,21 +6,22 @@ */ import { LevelLogger } from '../'; -import { LayoutSelectorDictionary, Size } from '../../../common/types'; +import { Size } from '../../../common/types'; import { HeadlessChromiumDriver } from '../../browsers'; import type { Layout } from './layout'; -export { - LayoutParams, - LayoutSelectorDictionary, - PageSizeParams, - PdfImageSize, - Size, -} from '../../../common/types'; +export interface LayoutSelectorDictionary { + screenshot: string; + renderComplete: string; + itemsCountAttribute: string; + timefilterDurationAttribute: string; +} + +export { LayoutParams, PageSizeParams, PdfImageSize, Size } from '../../../common/types'; +export { CanvasLayout } from './canvas_layout'; export { createLayout } from './create_layout'; export type { Layout } from './layout'; export { PreserveLayout } from './preserve_layout'; -export { CanvasLayout } from './canvas_layout'; export { PrintLayout } from './print_layout'; export const LayoutTypes = { diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts index 9b76b37f677a8..9833f340d47f3 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts @@ -7,33 +7,28 @@ import path from 'path'; import { CustomPageSize } from 'pdfmake/interfaces'; -import { getDefaultLayoutSelectors } from '../../../common'; import { LAYOUT_TYPES } from '../../../common/constants'; -import { LayoutSelectorDictionary, PageSizeParams, Size } from '../../../common/types'; -import type { LayoutInstance } from './'; +import { PageSizeParams, Size } from '../../../common/types'; +import { getDefaultLayoutSelectors, LayoutInstance } from './'; import { Layout } from './layout'; // We use a zoom of two to bump up the resolution of the screenshot a bit. const ZOOM: number = 2; export class PreserveLayout extends Layout implements LayoutInstance { - public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); + public readonly selectors = getDefaultLayoutSelectors(); public readonly groupCount = 1; public readonly height: number; public readonly width: number; private readonly scaledHeight: number; private readonly scaledWidth: number; - constructor(size: Size, layoutSelectors?: LayoutSelectorDictionary) { + constructor(size: Size) { super(LAYOUT_TYPES.PRESERVE_LAYOUT); this.height = size.height; this.width = size.width; this.scaledHeight = size.height * ZOOM; this.scaledWidth = size.width * ZOOM; - - if (layoutSelectors) { - this.selectors = layoutSelectors; - } } public getCssOverridesPath() { diff --git a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts index 77700cd085a52..03feb36496349 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts @@ -9,18 +9,17 @@ import path from 'path'; import { PageOrientation, PredefinedPageSize } from 'pdfmake/interfaces'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; import { LevelLogger } from '../'; -import { getDefaultLayoutSelectors } from '../../../common'; import { LAYOUT_TYPES } from '../../../common/constants'; -import { LayoutSelectorDictionary, Size } from '../../../common/types'; +import { Size } from '../../../common/types'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; -import type { LayoutInstance } from './'; +import { getDefaultLayoutSelectors, LayoutInstance, LayoutSelectorDictionary } from './'; import { Layout } from './layout'; export class PrintLayout extends Layout implements LayoutInstance { public readonly selectors: LayoutSelectorDictionary = { ...getDefaultLayoutSelectors(), - screenshot: '[data-shared-item]', + screenshot: '[data-shared-item]', // override '[data-shared-items-container]' }; public readonly groupCount = 2; private captureConfig: CaptureConfig; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js index d57a7d8305d51..adfb9aafc444e 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiCodeEditor } from '@elastic/eui'; +import { EuiCodeEditor } from '../../../../../shared_imports'; export const TabJson = ({ json }) => { const jsonString = JSON.stringify(json, null, 2); diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index c8d7f1d9f13f3..3478198dd9b68 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -9,4 +9,5 @@ export { extractQueryParams, indices, SectionLoading, + EuiCodeEditor, } from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index b291fa86bbf56..ad04ab706c2d4 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -5,9 +5,9 @@ * 2.0. */ -import type { CoreSetup, LegacyRequest } from 'src/core/server'; +import type { CoreSetup } from 'src/core/server'; -import { KibanaRequest, SavedObjectsClient } from '../../../../../src/core/server'; +import { SavedObjectsClient } from '../../../../../src/core/server'; import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; import type { AuthorizationServiceSetupInternal } from '../authorization'; import type { SpacesService } from '../plugin'; @@ -44,30 +44,25 @@ export function setupSavedObjects({ savedObjects, getSpacesService, }: SetupSavedObjectsParams) { - const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => - request instanceof KibanaRequest ? request : KibanaRequest.from(request); - savedObjects.setClientFactoryProvider( (repositoryFactory) => ({ request, includedHiddenTypes }) => { - const kibanaRequest = getKibanaRequest(request); return new SavedObjectsClient( - authz.mode.useRbacForRequest(kibanaRequest) + authz.mode.useRbacForRequest(request) ? repositoryFactory.createInternalRepository(includedHiddenTypes) - : repositoryFactory.createScopedRepository(kibanaRequest, includedHiddenTypes) + : repositoryFactory.createScopedRepository(request, includedHiddenTypes) ); } ); savedObjects.addClientWrapper(Number.MAX_SAFE_INTEGER - 1, 'security', ({ client, request }) => { - const kibanaRequest = getKibanaRequest(request); - return authz.mode.useRbacForRequest(kibanaRequest) + return authz.mode.useRbacForRequest(request) ? new SecureSavedObjectsClientWrapper({ actions: authz.actions, legacyAuditLogger, - auditLogger: audit.asScoped(kibanaRequest), + auditLogger: audit.asScoped(request), baseClient: client, checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest( - kibanaRequest + request ), errors: SavedObjectsClient.errors, getSpacesService, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx index 2bb6854739a32..3354637b9f745 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx @@ -12,6 +12,7 @@ import { EuiButtonEmpty, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiToolTip, } from '@elastic/eui'; import React, { useMemo, Fragment } from 'react'; import styled, { css } from 'styled-components'; @@ -25,15 +26,28 @@ const MyExceptionDetails = styled(EuiFlexItem)` ${({ theme }) => css` background-color: ${theme.eui.euiColorLightestShade}; padding: ${theme.eui.euiSize}; + .eventFiltersDescriptionList { + margin: ${theme.eui.euiSize} ${theme.eui.euiSize} 0 ${theme.eui.euiSize}; + } + .eventFiltersDescriptionListTitle { + width: 40%; + margin-top: 0; + margin-bottom: ${theme.eui.euiSizeS}; + } + .eventFiltersDescriptionListDescription { + width: 60%; + margin-top: 0; + margin-bottom: ${theme.eui.euiSizeS}; + } `} `; -const MyDescriptionListTitle = styled(EuiDescriptionListTitle)` - width: 40%; -`; - -const MyDescriptionListDescription = styled(EuiDescriptionListDescription)` - width: 60%; +const StyledCommentsSection = styled(EuiFlexItem)` + ${({ theme }) => css` + &&& { + margin: ${theme.eui.euiSizeXS} ${theme.eui.euiSize} ${theme.eui.euiSizeL} ${theme.eui.euiSize}; + } + `} `; const ExceptionDetailsComponent = ({ @@ -77,19 +91,28 @@ const ExceptionDetailsComponent = ({ return ( - + {descriptionListItems.map((item) => ( - {item.title} - - {item.description} - + + + {item.title} + + + + + {item.description} + + ))} - {commentsSection} + {commentsSection} ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx index 7429a934d557d..18b7298136302 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_entries.tsx @@ -15,6 +15,7 @@ import { EuiHideFor, EuiBadge, EuiBadgeGroup, + EuiToolTip, } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled, { css } from 'styled-components'; @@ -26,7 +27,12 @@ import * as i18n from '../../translations'; import { FormattedEntry } from '../../types'; const MyEntriesDetails = styled(EuiFlexItem)` - padding: ${({ theme }) => theme.eui.euiSize}; + ${({ theme }) => css` + padding: ${theme.eui.euiSize} ${theme.eui.euiSizeL} ${theme.eui.euiSizeL} ${theme.eui.euiSizeXS}; + &&& { + margin-left: 0; + } + `} `; const MyEditButton = styled(EuiButton)` @@ -46,8 +52,9 @@ const MyRemoveButton = styled(EuiButton)` `; const MyAndOrBadgeContainer = styled(EuiFlexItem)` - padding-top: ${({ theme }) => theme.eui.euiSizeXL}; - padding-bottom: ${({ theme }) => theme.eui.euiSizeS}; + ${({ theme }) => css` + padding: ${theme.eui.euiSizeXL} ${theme.eui.euiSize} ${theme.eui.euiSizeS} 0; + `} `; const MyActionButton = styled(EuiFlexItem)` @@ -132,7 +139,13 @@ const ExceptionEntriesComponent = ({ ); } else { - return values ?? getEmptyValue(); + return values ? ( + + {values} + + ) : ( + getEmptyValue() + ); } }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx index b73442b04c9b4..6a53f47baf6b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx @@ -84,7 +84,7 @@ const ExceptionItemComponent = ({ }, [loadingItemIds, exceptionItem.id]); return ( - + diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.test.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.test.tsx new file mode 100644 index 0000000000000..06b90a129136b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.test.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiButtonEmpty, EuiContextMenuItem } from '@elastic/eui'; +import { mount } from 'enzyme'; +import React from 'react'; +import { TestProviders } from '../../../mock'; +import { ShowTopNButton } from './show_top_n'; + +describe('show topN button', () => { + const defaultProps = { + field: 'signal.rule.name', + onClick: jest.fn(), + ownFocus: false, + showTopN: false, + timelineId: 'timeline-1', + value: ['rule_name'], + }; + + describe('button', () => { + test('should show EuiButtonIcon by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('EuiButtonIcon').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="show-top-field"]').first().prop('iconType')).toEqual( + 'visBarVertical' + ); + }); + + test('should support EuiButtonEmpty', () => { + const testProps = { + ...defaultProps, + Component: EuiButtonEmpty, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('EuiButtonIcon').exists()).toBeFalsy(); + expect(wrapper.find('EuiButtonEmpty').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="show-top-field"]').first().prop('iconType')).toEqual( + 'visBarVertical' + ); + }); + + test('should support EuiContextMenuItem', () => { + const testProps = { + ...defaultProps, + Component: EuiContextMenuItem, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('EuiButtonIcon').exists()).toBeFalsy(); + expect(wrapper.find('EuiContextMenuItem').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="show-top-field"]').first().prop('icon')).toEqual( + 'visBarVertical' + ); + }); + }); + + describe('tooltip', () => { + test('should show tooltip by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('EuiToolTip').exists()).toBeTruthy(); + }); + + test('should hide tooltip when topN is showed', () => { + const testProps = { + ...defaultProps, + showTopN: true, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('EuiToolTip').exists()).toBeFalsy(); + }); + + test('should hide tooltip by setting showTooltip to false', () => { + const testProps = { + ...defaultProps, + showTooltip: false, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('EuiToolTip').exists()).toBeFalsy(); + }); + }); + + describe('popover', () => { + test('should be able to show topN without a popover', () => { + const testProps = { + ...defaultProps, + enablePopOver: false, + showTopN: true, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="top-n"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="showTopNContainer"]').exists()).toBeFalsy(); + }); + test('should be able to show topN within a popover', () => { + const testProps = { + ...defaultProps, + enablePopOver: true, + showTopN: true, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="top-n"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="showTopNContainer"]').exists()).toBeTruthy(); + }); + }); + + describe('topN', () => { + test('should render with correct props', () => { + const onFilterAdded = jest.fn(); + const testProps = { + ...defaultProps, + enablePopOver: true, + showTopN: true, + onFilterAdded, + }; + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="top-n"]').prop('field')).toEqual(testProps.field); + expect(wrapper.find('[data-test-subj="top-n"]').prop('value')).toEqual(testProps.value); + expect(wrapper.find('[data-test-subj="top-n"]').prop('toggleTopN')).toEqual( + testProps.onClick + ); + expect(wrapper.find('[data-test-subj="top-n"]').prop('timelineId')).toEqual( + testProps.timelineId + ); + expect(wrapper.find('[data-test-subj="top-n"]').prop('onFilterAdded')).toEqual( + testProps.onFilterAdded + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx index dbb00eb90e2af..0d6e59483fbc4 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx @@ -6,7 +6,13 @@ */ import React, { useMemo } from 'react'; -import { EuiButtonEmpty, EuiButtonIcon, EuiContextMenuItem, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiPopover, + EuiButtonIcon, + EuiContextMenuItem, + EuiToolTip, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { StatefulTopN } from '../../top_n'; import { TimelineId } from '../../../../../common/types/timeline'; @@ -23,8 +29,11 @@ const SHOW_TOP = (fieldName: string) => }); interface Props { - /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ + /** When `Component` is used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality. + * When `Component` is used with `EuiContextMenu`, we pass EuiContextMenuItem to render the right style. + */ Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon | typeof EuiContextMenuItem; + enablePopOver?: boolean; field: string; onClick: () => void; onFilterAdded?: () => void; @@ -38,6 +47,7 @@ interface Props { export const ShowTopNButton: React.FC = React.memo( ({ Component, + enablePopOver, field, onClick, onFilterAdded, @@ -58,7 +68,7 @@ export const ShowTopNButton: React.FC = React.memo( : SourcererScopeName.default; const { browserFields, indexPattern } = useSourcererScope(activeScope); - const button = useMemo( + const basicButton = useMemo( () => Component ? ( = React.memo( [Component, field, onClick] ); + const button = useMemo( + () => + showTooltip && !showTopN ? ( + + } + > + {basicButton} + + ) : ( + basicButton + ), + [basicButton, field, ownFocus, showTooltip, showTopN, value] + ); + + const topNPannel = useMemo( + () => ( + + ), + [browserFields, field, indexPattern, onClick, onFilterAdded, timelineId, value] + ); + return showTopN ? ( - - ) : showTooltip ? ( - - } - > - {button} - + enablePopOver ? ( + + {topNPannel} + + ) : ( + topNPannel + ) ) : ( button ); diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.test.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.test.tsx new file mode 100644 index 0000000000000..2ef72571cf307 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useRef } from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { useHoverActionItems, UseHoverActionItemsProps } from './use_hover_action_items'; +import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { DataProvider } from '../../../../common/types/timeline'; + +jest.mock('../../lib/kibana'); +jest.mock('../../hooks/use_selector'); +jest.mock('../../containers/sourcerer', () => ({ + useSourcererScope: jest.fn().mockReturnValue({ browserFields: {} }), +})); + +describe('useHoverActionItems', () => { + const defaultProps: UseHoverActionItemsProps = ({ + dataProvider: [{} as DataProvider], + defaultFocusedButtonRef: null, + field: 'signal.rule.name', + handleHoverActionClicked: jest.fn(), + isObjectArray: true, + ownFocus: false, + showTopN: false, + stKeyboardEvent: undefined, + toggleColumn: jest.fn(), + toggleTopN: jest.fn(), + values: ['rule name'], + } as unknown) as UseHoverActionItemsProps; + + beforeEach(() => { + (useDeepEqualSelector as jest.Mock).mockImplementation((cb) => { + return cb(); + }); + }); + afterEach(() => { + (useDeepEqualSelector as jest.Mock).mockClear(); + }); + + test('should return allActionItems', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + const defaultFocusedButtonRef = useRef(null); + const testProps = { + ...defaultProps, + defaultFocusedButtonRef, + }; + return useHoverActionItems(testProps); + }); + await waitForNextUpdate(); + + expect(result.current.allActionItems).toHaveLength(6); + expect(result.current.allActionItems[0].props['data-test-subj']).toEqual( + 'hover-actions-filter-for' + ); + expect(result.current.allActionItems[1].props['data-test-subj']).toEqual( + 'hover-actions-filter-out' + ); + expect(result.current.allActionItems[2].props['data-test-subj']).toEqual( + 'hover-actions-toggle-column' + ); + expect(result.current.allActionItems[3].props['data-test-subj']).toEqual( + 'hover-actions-add-timeline' + ); + expect(result.current.allActionItems[4].props['data-test-subj']).toEqual( + 'hover-actions-show-top-n' + ); + expect(result.current.allActionItems[5].props['data-test-subj']).toEqual( + 'hover-actions-copy-button' + ); + }); + }); + + test('should return overflowActionItems', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + const defaultFocusedButtonRef = useRef(null); + const testProps = { + ...defaultProps, + defaultFocusedButtonRef, + enableOverflowButton: true, + }; + return useHoverActionItems(testProps); + }); + await waitForNextUpdate(); + + expect(result.current.overflowActionItems).toHaveLength(3); + expect(result.current.overflowActionItems[0].props['data-test-subj']).toEqual( + 'hover-actions-filter-for' + ); + expect(result.current.overflowActionItems[1].props['data-test-subj']).toEqual( + 'hover-actions-filter-out' + ); + expect(result.current.overflowActionItems[2].props['data-test-subj']).toEqual( + 'more-actions-signal.rule.name' + ); + expect(result.current.overflowActionItems[2].props.items[0].props['data-test-subj']).toEqual( + 'hover-actions-toggle-column' + ); + + expect(result.current.overflowActionItems[2].props.items[1].props['data-test-subj']).toEqual( + 'hover-actions-add-timeline' + ); + expect(result.current.overflowActionItems[2].props.items[2].props['data-test-subj']).toEqual( + 'hover-actions-show-top-n' + ); + expect(result.current.overflowActionItems[2].props.items[3].props['data-test-subj']).toEqual( + 'hover-actions-copy-button' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx index c8dc7b59b8a7d..a7e4a528ca1b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/use_hover_action_items.tsx @@ -21,7 +21,7 @@ import { useSourcererScope } from '../../containers/sourcerer'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { ShowTopNButton } from './actions/show_top_n'; -interface UseHoverActionItemsProps { +export interface UseHoverActionItemsProps { dataProvider?: DataProvider | DataProvider[]; dataType?: string; defaultFocusedButtonRef: React.MutableRefObject; @@ -43,7 +43,7 @@ interface UseHoverActionItemsProps { values?: string[] | string | null; } -interface UseHoverActionItems { +export interface UseHoverActionItems { overflowActionItems: JSX.Element[]; allActionItems: JSX.Element[]; } @@ -116,7 +116,6 @@ export const useHoverActionItems = ({ */ const showFilters = values != null && (enableOverflowButton || (!showTopN && !enableOverflowButton)); - const allItems = useMemo( () => [ @@ -243,7 +242,7 @@ export const useHoverActionItems = ({ ] ) as JSX.Element[]; - const overflowBtn = useMemo( + const showTopNBtn = useMemo( () => ( (showTopN ? [overflowBtn] : allItems), [ + const allActionItems = useMemo(() => (showTopN ? [showTopNBtn] : allItems), [ allItems, - overflowBtn, + showTopNBtn, showTopN, ]); diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx index 951d921653c15..085b2098cde35 100644 --- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx @@ -140,6 +140,7 @@ export const defaultCellActions: TGridCellAction[] = [ }) && ( theme.eui.euiSizeS}; `; const getAlertsCountTableColumns = ( @@ -70,7 +69,7 @@ export const AlertsCount = memo(({ loading, selectedStackByOpt <> {loading && } - + } diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 8466e19100f73..e206f85df6548 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -11,7 +11,7 @@ import { Dispatch } from 'redux'; import { useHistory, useLocation } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiSpacer, EuiHorizontalRule, EuiText } from '@elastic/eui'; +import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; @@ -192,7 +192,7 @@ export const EventFiltersListPage = memo(() => { title={ } subtitle={ABOUT_EVENT_FILTERS} @@ -207,7 +207,7 @@ export const EventFiltersListPage = memo(() => { > ) @@ -236,11 +236,11 @@ export const EventFiltersListPage = memo(() => { - + )} diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts index 4c127ee47003f..ae8012711fbf1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/translations.ts @@ -54,6 +54,5 @@ export const getGetErrorMessage = (getError: ServerApiError) => { export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + - 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch.', }); diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_edit.test.ts b/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_edit.test.ts index 2087325056649..8adf1e988ff1e 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_edit.test.ts +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/repository_edit.test.ts @@ -215,7 +215,7 @@ describe('', () => { ); expect(find('readOnlyToggle').props()['aria-checked']).toBe(settings.readonly); - const codeEditor = testBed.component.find('EuiCodeEditor'); + const codeEditor = testBed.component.find('EuiCodeEditor').at(1); expect(JSON.parse(codeEditor.props().value as string)).toEqual({ loadDefault: true, conf1: 'foo', diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx index 5457e22d50b89..2cece7d6d396a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/hdfs_settings.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode, - EuiCodeEditor, EuiDescribedFormGroup, EuiFieldText, EuiFormRow, @@ -20,6 +19,7 @@ import { } from '@elastic/eui'; import { HDFSRepository, Repository, SourceRepository } from '../../../../../common/types'; +import { EuiCodeEditor } from '../../../../shared_imports'; import { RepositorySettingsValidation } from '../../../services/validation'; import { ChunkSizeField, MaxSnapshotsField, MaxRestoreField } from './common'; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx index 4210279363780..2974a7b686039 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_review.tsx @@ -8,7 +8,6 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiCodeEditor, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, @@ -24,6 +23,7 @@ import { EuiToolTip, } from '@elastic/eui'; import { serializeRestoreSettings } from '../../../../../common/lib'; +import { EuiCodeEditor } from '../../../../shared_imports'; import { useServices } from '../../../app_context'; import { StepProps } from './'; import { CollapsibleIndicesList } from '../../collapsible_lists/collapsible_indices_list'; diff --git a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx index c37be0907a27a..446e3f4c3c4ab 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/restore_snapshot_form/steps/step_settings.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiCode, - EuiCodeEditor, EuiComboBox, EuiDescribedFormGroup, EuiFlexGroup, @@ -23,6 +22,7 @@ import { EuiCallOut, } from '@elastic/eui'; import { RestoreSettings } from '../../../../../common/types'; +import { EuiCodeEditor } from '../../../../shared_imports'; import { REMOVE_INDEX_SETTINGS_SUGGESTIONS } from '../../../constants'; import { useCore, useServices } from '../../../app_context'; import { StepProps } from './'; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx index 733fdc3d7b653..4944d9bde379a 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/policy_list/policy_details/tabs/tab_history.tsx @@ -9,7 +9,6 @@ import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiLink, @@ -23,6 +22,7 @@ import { } from '@elastic/eui'; import { SlmPolicy } from '../../../../../../../common/types'; +import { EuiCodeEditor } from '../../../../../../shared_imports'; import { FormattedDateTime } from '../../../../../components'; import { linkToSnapshot } from '../../../../../services/navigation'; import { useServices } from '../../../../../app_context'; diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx index a349853485e45..8cb86fd4952fa 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/repository_list/repository_details/type_details/default_details.tsx @@ -9,9 +9,10 @@ import 'brace/theme/textmate'; import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCodeEditor, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import { Repository } from '../../../../../../../common/types'; +import { EuiCodeEditor } from '../../../../../../shared_imports'; interface Props { repository: Repository; diff --git a/x-pack/plugins/snapshot_restore/public/shared_imports.ts b/x-pack/plugins/snapshot_restore/public/shared_imports.ts index 84c195a51950b..d1b9f37703c0c 100644 --- a/x-pack/plugins/snapshot_restore/public/shared_imports.ts +++ b/x-pack/plugins/snapshot_restore/public/shared_imports.ts @@ -22,6 +22,7 @@ export { useRequest, UseRequestConfig, WithPrivileges, + EuiCodeEditor, } from '../../../../src/plugins/es_ui_shared/public'; export { APP_WRAPPER_CLASS } from '../../../../src/core/public'; diff --git a/x-pack/plugins/timelines/public/components/hover_actions/actions/overflow.tsx b/x-pack/plugins/timelines/public/components/hover_actions/actions/overflow.tsx index a02257d72530e..a10c96f3aa0ae 100644 --- a/x-pack/plugins/timelines/public/components/hover_actions/actions/overflow.tsx +++ b/x-pack/plugins/timelines/public/components/hover_actions/actions/overflow.tsx @@ -16,6 +16,7 @@ import { EuiToolTip, } from '@elastic/eui'; +import styled from 'styled-components'; import { stopPropagationAndPreventDefault } from '../../../../common'; import { TooltipWithKeyboardShortcut } from '../../tooltip_with_keyboard_shortcut'; import { getAdditionalScreenReaderOnlyContext } from '../utils'; @@ -34,6 +35,10 @@ export interface OverflowButtonProps extends HoverActionComponentProps { isOverflowPopoverOpen: boolean; } +const StyledEuiContextMenuPanel = styled(EuiContextMenuPanel)` + visibility: inherit; +`; + const OverflowButton: React.FC = React.memo( ({ closePopOver, @@ -91,9 +96,10 @@ const OverflowButton: React.FC = React.memo( isOpen={isOverflowPopoverOpen} closePopover={closePopOver} panelPaddingSize="none" + panelClassName="withHoverActions__popover" anchorPosition="downLeft" > - + ), [ diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx index e3e767a81b01d..543ecc7dee195 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_agg_form.tsx @@ -6,7 +6,7 @@ */ import React, { useContext, useMemo } from 'react'; -import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { EuiFormRow, EuiIcon, EuiSelect, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { CreateTransformWizardContext } from '../../../../wizard/wizard'; @@ -72,10 +72,22 @@ export const FilterAggForm: PivotAggsConfigFilter['AggFormComponent'] = ({ <> + <> + + + } + > + + + } > void; actionTypeRegistry: ActionTypeRegistryContract; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts index 749cf53cf740b..5049a37c317dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/common_transformations.ts @@ -6,7 +6,7 @@ */ import { AlertExecutionStatus } from '../../../../../alerting/common'; import { AsApiContract, RewriteRequestCase } from '../../../../../actions/common'; -import { Alert, AlertAction } from '../../../types'; +import { Alert, AlertAction, ResolvedRule } from '../../../types'; const transformAction: RewriteRequestCase = ({ group, @@ -59,3 +59,16 @@ export const transformAlert: RewriteRequestCase = ({ scheduledTaskId, ...rest, }); + +export const transformResolvedRule: RewriteRequestCase = ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + alias_target_id, + outcome, + ...rest +}: any) => { + return { + ...transformAlert(rest), + alias_target_id, + outcome, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts index a0b090a474e28..c499f7955e2fe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/index.ts @@ -22,3 +22,4 @@ export { loadAlertState } from './state'; export { unmuteAlertInstance } from './unmute_alert'; export { unmuteAlert, unmuteAlerts } from './unmute'; export { updateAlert } from './update'; +export { resolveRule } from './resolve_rule'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.test.ts new file mode 100644 index 0000000000000..14b64f56f31ff --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { resolveRule } from './resolve_rule'; +import uuid from 'uuid'; + +const http = httpServiceMock.createStartContract(); + +describe('resolveRule', () => { + test('should call get API with base parameters', async () => { + const ruleId = `${uuid.v4()}/`; + const ruleIdEncoded = encodeURIComponent(ruleId); + const resolvedValue = { + id: '1/', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'canvas-element.@created', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: ['sdfsdf'], + name: 'dfsdfdsf', + enabled: true, + throttle: '1h', + rule_type_id: '.index-threshold', + created_by: 'elastic', + updated_by: 'elastic', + created_at: '2021-04-01T20:29:18.652Z', + updated_at: '2021-04-01T20:33:38.260Z', + api_key_owner: 'elastic', + notify_when: 'onThrottleInterval', + mute_all: false, + muted_alert_ids: [], + scheduled_task_id: '1', + execution_status: { status: 'ok', last_execution_date: '2021-04-01T21:16:46.709Z' }, + actions: [ + { + group: 'threshold met', + id: '1', + params: { documents: [{ dsfsdf: 1212 }] }, + connector_type_id: '.index', + }, + ], + outcome: 'aliasMatch', + alias_target_id: '2', + }; + http.get.mockResolvedValueOnce(resolvedValue); + + expect(await resolveRule({ http, ruleId })).toEqual({ + id: '1/', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000], + index: ['.kibana'], + timeField: 'canvas-element.@created', + }, + consumer: 'alerts', + schedule: { interval: '1m' }, + tags: ['sdfsdf'], + name: 'dfsdfdsf', + enabled: true, + throttle: '1h', + alertTypeId: '.index-threshold', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: '2021-04-01T20:29:18.652Z', + updatedAt: '2021-04-01T20:33:38.260Z', + apiKeyOwner: 'elastic', + notifyWhen: 'onThrottleInterval', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '1', + executionStatus: { status: 'ok', lastExecutionDate: '2021-04-01T21:16:46.709Z' }, + actions: [ + { + group: 'threshold met', + id: '1', + params: { documents: [{ dsfsdf: 1212 }] }, + actionTypeId: '.index', + }, + ], + outcome: 'aliasMatch', + alias_target_id: '2', + }); + expect(http.get).toHaveBeenCalledWith(`/internal/alerting/rule/${ruleIdEncoded}/_resolve`); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.ts new file mode 100644 index 0000000000000..bc2a19d298f8a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/resolve_rule.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { HttpSetup } from 'kibana/public'; +import { ResolvedRule } from '../../../types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; +import { transformResolvedRule } from './common_transformations'; + +export async function resolveRule({ + http, + ruleId, +}: { + http: HttpSetup; + ruleId: string; +}): Promise { + const res = await http.get( + `${INTERNAL_BASE_ALERTING_API_PATH}/rule/${encodeURIComponent(ruleId)}/_resolve` + ); + return transformResolvedRule(res); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx index 41c70a6737fa0..441daad1a50bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -8,45 +8,150 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory, createLocation } from 'history'; import { ToastsApi } from 'kibana/public'; -import { AlertDetailsRoute, getAlertData } from './alert_details_route'; +import { AlertDetailsRoute, getRuleData } from './alert_details_route'; import { Alert } from '../../../../types'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; +import { spacesOssPluginMock } from 'src/plugins/spaces_oss/public/mocks'; +import { useKibana } from '../../../../common/lib/kibana'; jest.mock('../../../../common/lib/kibana'); +class NotFoundError extends Error { + public readonly body: { + statusCode: number; + name: string; + } = { + statusCode: 404, + name: 'Not found', + }; + + constructor(message: string | undefined) { + super(message); + } +} + describe('alert_details_route', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const spacesOssMock = spacesOssPluginMock.createStartContract(); + async function setup() { + const useKibanaMock = useKibana as jest.Mocked; + // eslint-disable-next-line react-hooks/rules-of-hooks + useKibanaMock().services.spacesOss = spacesOssMock; + } + it('render a loader while fetching data', () => { - const alert = mockAlert(); + const rule = mockRule(); expect( shallow( - + ).containsMatchingElement() ).toBeTruthy(); }); + + it('redirects to another page if fetched rule is an aliasMatch', async () => { + await setup(); + const rule = mockRule(); + const { loadAlert, resolveRule } = mockApis(); + + loadAlert.mockImplementationOnce(async () => { + throw new NotFoundError('OMG'); + }); + resolveRule.mockImplementationOnce(async () => ({ + ...rule, + id: 'new_id', + outcome: 'aliasMatch', + alias_target_id: rule.id, + })); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).toHaveBeenCalledWith(rule.id); + expect((spacesOssMock as any).ui.redirectLegacyUrl).toHaveBeenCalledWith( + `insightsAndAlerting/triggersActions/rule/new_id`, + `rule` + ); + }); + + it('shows warning callout if fetched rule is a conflict', async () => { + await setup(); + const rule = mockRule(); + const ruleType = { + id: rule.alertTypeId, + name: 'type name', + authorizedConsumers: ['consumer'], + }; + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); + + loadAlert.mockImplementationOnce(async () => { + throw new NotFoundError('OMG'); + }); + loadAlertTypes.mockImplementationOnce(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => []); + resolveRule.mockImplementationOnce(async () => ({ + ...rule, + id: 'new_id', + outcome: 'conflict', + alias_target_id: rule.id, + })); + const wrapper = mountWithIntl( + + ); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).toHaveBeenCalledWith(rule.id); + expect((spacesOssMock as any).ui.components.getLegacyUrlConflict).toHaveBeenCalledWith({ + currentObjectId: 'new_id', + objectNoun: 'rule', + otherObjectId: rule.id, + otherObjectPath: `insightsAndAlerting/triggersActions/rule/${rule.id}`, + }); + }); }); -describe('getAlertData useEffect handler', () => { +describe('getRuleData useEffect handler', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('fetches alert', async () => { - const alert = mockAlert(); - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + it('fetches rule', async () => { + const rule = mockRule(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementationOnce(async () => alert); + loadAlert.mockImplementationOnce(async () => rule); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -54,45 +159,47 @@ describe('getAlertData useEffect handler', () => { toastNotifications ); - expect(loadAlert).toHaveBeenCalledWith(alert.id); - expect(setAlert).toHaveBeenCalledWith(alert); + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).not.toHaveBeenCalled(); + expect(setAlert).toHaveBeenCalledWith(rule); }); - it('fetches alert and action types', async () => { - const actionType = { + it('fetches rule and connector types', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const alertType = { - id: alert.alertTypeId, + const ruleType = { + id: rule.alertTypeId, name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); - loadAlertTypes.mockImplementation(async () => [alertType]); - loadActionTypes.mockImplementation(async () => [actionType]); + loadAlert.mockImplementation(async () => rule); + loadAlertTypes.mockImplementation(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => [connectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -102,29 +209,76 @@ describe('getAlertData useEffect handler', () => { expect(loadAlertTypes).toHaveBeenCalledTimes(1); expect(loadActionTypes).toHaveBeenCalledTimes(1); + expect(resolveRule).not.toHaveBeenCalled(); + + expect(setAlert).toHaveBeenCalledWith(rule); + expect(setAlertType).toHaveBeenCalledWith(ruleType); + expect(setActionTypes).toHaveBeenCalledWith([connectorType]); + }); + + it('fetches rule using resolve if initial GET results in a 404 error', async () => { + const connectorType = { + id: '.server-log', + name: 'Server log', + enabled: true, + }; + const rule = mockRule({ + actions: [ + { + group: '', + id: uuid.v4(), + actionTypeId: connectorType.id, + params: {}, + }, + ], + }); + + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); + const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); + + loadAlert.mockImplementationOnce(async () => { + throw new NotFoundError('OMG'); + }); + resolveRule.mockImplementationOnce(async () => rule); + + const toastNotifications = ({ + addDanger: jest.fn(), + } as unknown) as ToastsApi; + await getRuleData( + rule.id, + loadAlert, + loadAlertTypes, + resolveRule, + loadActionTypes, + setAlert, + setAlertType, + setActionTypes, + toastNotifications + ); - expect(setAlertType).toHaveBeenCalledWith(alertType); - expect(setActionTypes).toHaveBeenCalledWith([actionType]); + expect(loadAlert).toHaveBeenCalledWith(rule.id); + expect(resolveRule).toHaveBeenCalledWith(rule.id); + expect(setAlert).toHaveBeenCalledWith(rule); }); - it('displays an error if the alert isnt found', async () => { - const actionType = { + it('displays an error if fetching the rule results in a non-404 error', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); loadAlert.mockImplementation(async () => { @@ -134,10 +288,11 @@ describe('getAlertData useEffect handler', () => { const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -150,40 +305,41 @@ describe('getAlertData useEffect handler', () => { }); }); - it('displays an error if the alert type isnt loaded', async () => { - const actionType = { + it('displays an error if the rule type isnt loaded', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); + loadAlert.mockImplementation(async () => rule); loadAlertTypes.mockImplementation(async () => { - throw new Error('OMG no alert type'); + throw new Error('OMG no rule type'); }); - loadActionTypes.mockImplementation(async () => [actionType]); + loadActionTypes.mockImplementation(async () => [connectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -192,48 +348,49 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: 'Unable to load rule: OMG no alert type', + title: 'Unable to load rule: OMG no rule type', }); }); - it('displays an error if the action type isnt loaded', async () => { - const actionType = { + it('displays an error if the connector type isnt loaded', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const alertType = { - id: alert.alertTypeId, + const ruleType = { + id: rule.alertTypeId, name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); + loadAlert.mockImplementation(async () => rule); - loadAlertTypes.mockImplementation(async () => [alertType]); + loadAlertTypes.mockImplementation(async () => [ruleType]); loadActionTypes.mockImplementation(async () => { - throw new Error('OMG no action type'); + throw new Error('OMG no connector type'); }); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -242,46 +399,47 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: 'Unable to load rule: OMG no action type', + title: 'Unable to load rule: OMG no connector type', }); }); - it('displays an error if the alert type isnt found', async () => { - const actionType = { + it('displays an error if the rule type isnt found', async () => { + const connectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: actionType.id, + actionTypeId: connectorType.id, params: {}, }, ], }); - const alertType = { + const ruleType = { id: uuid.v4(), name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); - loadAlertTypes.mockImplementation(async () => [alertType]); - loadActionTypes.mockImplementation(async () => [actionType]); + loadAlert.mockImplementation(async () => rule); + loadAlertTypes.mockImplementation(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => [connectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -290,57 +448,58 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: `Unable to load rule: Invalid Alert Type: ${alert.alertTypeId}`, + title: `Unable to load rule: Invalid Rule Type: ${rule.alertTypeId}`, }); }); it('displays an error if an action type isnt found', async () => { - const availableActionType = { + const availableConnectorType = { id: '.server-log', name: 'Server log', enabled: true, }; - const missingActionType = { + const missingConnectorType = { id: '.noop', name: 'No Op', enabled: true, }; - const alert = mockAlert({ + const rule = mockRule({ actions: [ { group: '', id: uuid.v4(), - actionTypeId: availableActionType.id, + actionTypeId: availableConnectorType.id, params: {}, }, { group: '', id: uuid.v4(), - actionTypeId: missingActionType.id, + actionTypeId: missingConnectorType.id, params: {}, }, ], }); - const alertType = { + const ruleType = { id: uuid.v4(), name: 'type name', }; - const { loadAlert, loadAlertTypes, loadActionTypes } = mockApis(); + const { loadAlert, loadAlertTypes, loadActionTypes, resolveRule } = mockApis(); const { setAlert, setAlertType, setActionTypes } = mockStateSetter(); - loadAlert.mockImplementation(async () => alert); - loadAlertTypes.mockImplementation(async () => [alertType]); - loadActionTypes.mockImplementation(async () => [availableActionType]); + loadAlert.mockImplementation(async () => rule); + loadAlertTypes.mockImplementation(async () => [ruleType]); + loadActionTypes.mockImplementation(async () => [availableConnectorType]); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertData( - alert.id, + await getRuleData( + rule.id, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, @@ -349,7 +508,7 @@ describe('getAlertData useEffect handler', () => { ); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: `Unable to load rule: Invalid Action Type: ${missingActionType.id}`, + title: `Unable to load rule: Invalid Connector Type: ${missingConnectorType.id}`, }); }); }); @@ -359,6 +518,7 @@ function mockApis() { loadAlert: jest.fn(), loadAlertTypes: jest.fn(), loadActionTypes: jest.fn(), + resolveRule: jest.fn(), }; } @@ -370,23 +530,23 @@ function mockStateSetter() { }; } -function mockRouterProps(alert: Alert) { +function mockRouterProps(rule: Alert) { return { match: { isExact: false, - path: `/rule/${alert.id}`, + path: `/rule/${rule.id}`, url: '', - params: { ruleId: alert.id }, + params: { ruleId: rule.id }, }, history: createMemoryHistory(), - location: createLocation(`/rule/${alert.id}`), + location: createLocation(`/rule/${rule.id}`), }; } -function mockAlert(overloads: Partial = {}): Alert { +function mockRule(overloads: Partial = {}): Alert { return { id: uuid.v4(), enabled: true, - name: `alert-${uuid.v4()}`, + name: `rule-${uuid.v4()}`, tags: [], alertTypeId: '.noop', consumer: 'consumer', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index 2d6db5f6330cc..b6279d7fca100 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -9,7 +9,8 @@ import { i18n } from '@kbn/i18n'; import React, { useState, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { ToastsApi } from 'kibana/public'; -import { Alert, AlertType, ActionType } from '../../../../types'; +import { EuiSpacer } from '@elastic/eui'; +import { Alert, AlertType, ActionType, ResolvedRule } from '../../../../types'; import { AlertDetailsWithApi as AlertDetails } from './alert_details'; import { throwIfAbsent, throwIfIsntContained } from '../../../lib/value_validators'; import { @@ -27,7 +28,7 @@ type AlertDetailsRouteProps = RouteComponentProps<{ ruleId: string; }> & Pick & - Pick; + Pick; export const AlertDetailsRoute: React.FunctionComponent = ({ match: { @@ -36,63 +37,127 @@ export const AlertDetailsRoute: React.FunctionComponent loadAlert, loadAlertTypes, loadActionTypes, + resolveRule, }) => { const { http, notifications: { toasts }, + spacesOss, } = useKibana().services; - const [alert, setAlert] = useState(null); + const { basePath } = http; + + const [alert, setAlert] = useState(null); const [alertType, setAlertType] = useState(null); const [actionTypes, setActionTypes] = useState(null); const [refreshToken, requestRefresh] = React.useState(); useEffect(() => { - getAlertData( + getRuleData( ruleId, loadAlert, loadAlertTypes, + resolveRule, loadActionTypes, setAlert, setAlertType, setActionTypes, toasts ); - }, [ruleId, http, loadActionTypes, loadAlert, loadAlertTypes, toasts, refreshToken]); + }, [ruleId, http, loadActionTypes, loadAlert, loadAlertTypes, resolveRule, toasts, refreshToken]); + + useEffect(() => { + if (alert) { + const outcome = (alert as ResolvedRule).outcome; + if (outcome === 'aliasMatch' && spacesOss.isSpacesAvailable) { + // This rule has been resolved from a legacy URL - redirect the user to the new URL and display a toast. + const path = basePath.prepend(`insightsAndAlerting/triggersActions/rule/${alert.id}`); + spacesOss.ui.redirectLegacyUrl( + path, + i18n.translate('xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', { + defaultMessage: 'rule', + }) + ); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alert]); + + const getLegacyUrlConflictCallout = () => { + const outcome = (alert as ResolvedRule).outcome; + const aliasTargetId = (alert as ResolvedRule).alias_target_id; + if (outcome === 'conflict' && aliasTargetId && spacesOss.isSpacesAvailable) { + // We have resolved to one rule, but there is another one with a legacy URL associated with this page. Display a + // callout with a warning for the user, and provide a way for them to navigate to the other rule. + const otherRulePath = basePath.prepend( + `insightsAndAlerting/triggersActions/rule/${aliasTargetId}` + ); + return ( + <> + + {spacesOss.ui.components.getLegacyUrlConflict({ + objectNoun: i18n.translate( + 'xpack.triggersActionsUI.sections.alertDetails.redirectObjectNoun', + { + defaultMessage: 'rule', + } + ), + currentObjectId: alert?.id!, + otherObjectId: aliasTargetId, + otherObjectPath: otherRulePath, + })} + + ); + } + return null; + }; return alert && alertType && actionTypes ? ( - requestRefresh(Date.now())} - /> + <> + {getLegacyUrlConflictCallout()} + requestRefresh(Date.now())} + /> + ) : ( ); }; -export async function getAlertData( - alertId: string, +export async function getRuleData( + ruleId: string, loadAlert: AlertApis['loadAlert'], loadAlertTypes: AlertApis['loadAlertTypes'], + resolveRule: AlertApis['resolveRule'], loadActionTypes: ActionApis['loadActionTypes'], - setAlert: React.Dispatch>, + setAlert: React.Dispatch>, setAlertType: React.Dispatch>, setActionTypes: React.Dispatch>, toasts: Pick ) { try { - const loadedAlert = await loadAlert(alertId); - setAlert(loadedAlert); + let loadedRule: Alert | ResolvedRule; + try { + loadedRule = await loadAlert(ruleId); + } catch (err) { + // Try resolving this rule id if the error is a 404, otherwise re-throw + if (err?.body?.statusCode !== 404) { + throw err; + } + loadedRule = await resolveRule(ruleId); + } + setAlert(loadedRule); const [loadedAlertType, loadedActionTypes] = await Promise.all([ loadAlertTypes() - .then((types) => types.find((type) => type.id === loadedAlert.alertTypeId)) - .then(throwIfAbsent(`Invalid Alert Type: ${loadedAlert.alertTypeId}`)), + .then((types) => types.find((type) => type.id === loadedRule.alertTypeId)) + .then(throwIfAbsent(`Invalid Rule Type: ${loadedRule.alertTypeId}`)), loadActionTypes().then( throwIfIsntContained( - new Set(loadedAlert.actions.map((action) => action.actionTypeId)), - (requiredActionType: string) => `Invalid Action Type: ${requiredActionType}`, + new Set(loadedRule.actions.map((action) => action.actionTypeId)), + (requiredActionType: string) => `Invalid Connector Type: ${requiredActionType}`, (action: ActionType) => action.id ) ), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx index 7d314cef55680..806f649e7d033 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -36,6 +36,7 @@ describe('with_bulk_alert_api_operations', () => { expect(typeof props.deleteAlert).toEqual('function'); expect(typeof props.loadAlert).toEqual('function'); expect(typeof props.loadAlertTypes).toEqual('function'); + expect(typeof props.resolveRule).toEqual('function'); return

; }; @@ -220,6 +221,24 @@ describe('with_bulk_alert_api_operations', () => { expect(alertApi.loadAlert).toHaveBeenCalledWith({ alertId, http }); }); + it('resolveRule calls the resolveRule api', () => { + const { http } = useKibanaMock().services; + const ComponentToExtend = ({ + resolveRule, + ruleId, + }: ComponentOpts & { ruleId: Alert['id'] }) => { + return ; + }; + + const ExtendedComponent = withBulkAlertOperations(ComponentToExtend); + const ruleId = uuid.v4(); + const component = mount(); + component.find('button').simulate('click'); + + expect(alertApi.resolveRule).toHaveBeenCalledTimes(1); + expect(alertApi.resolveRule).toHaveBeenCalledWith({ ruleId, http }); + }); + it('loadAlertTypes calls the loadAlertTypes api', () => { const { http } = useKibanaMock().services; const ComponentToExtend = ({ loadAlertTypes }: ComponentOpts) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 983fe5641e62b..59919c202277c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -13,6 +13,7 @@ import { AlertTaskState, AlertInstanceSummary, AlertingFrameworkHealth, + ResolvedRule, } from '../../../../types'; import { deleteAlerts, @@ -31,6 +32,7 @@ import { loadAlertInstanceSummary, loadAlertTypes, alertingFrameworkHealth, + resolveRule, } from '../../../lib/alert_api'; import { useKibana } from '../../../../common/lib/kibana'; @@ -62,6 +64,7 @@ export interface ComponentOpts { loadAlertInstanceSummary: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; getHealth: () => Promise; + resolveRule: (id: Alert['id']) => Promise; } export type PropsWithOptionalApiHandlers = Omit & Partial; @@ -132,6 +135,7 @@ export function withBulkAlertOperations( loadAlertInstanceSummary({ http, alertId }) } loadAlertTypes={async () => loadAlertTypes({ http })} + resolveRule={async (ruleId: Alert['id']) => resolveRule({ http, ruleId })} getHealth={async () => alertingFrameworkHealth({ http })} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts index a1a0184198dfd..f8aa483711c30 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -8,7 +8,7 @@ import React from 'react'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; - +import { spacesOssPluginMock } from '../../../../../../../src/plugins/spaces_oss/public/mocks'; import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/public/mocks'; import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; import { TriggersAndActionsUiServices } from '../../../application/app'; @@ -45,6 +45,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => { element: ({ style: { cursor: 'pointer' }, } as unknown) as HTMLElement, + spacesOss: spacesOssPluginMock.createStartContract(), } as TriggersAndActionsUiServices; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index 7661eefba7f65..36d6964ce7753 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -26,6 +26,7 @@ import { PluginStartContract as AlertingStart } from '../../alerting/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import type { SpacesOssPluginStart } from '../../../../src/plugins/spaces_oss/public'; import { getAddConnectorFlyoutLazy } from './common/get_add_connector_flyout'; import { getEditConnectorFlyoutLazy } from './common/get_edit_connector_flyout'; @@ -73,6 +74,7 @@ interface PluginsStart { charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; + spacesOss: SpacesOssPluginStart; navigateToApp: CoreStart['application']['navigateToApp']; features: FeaturesPluginStart; } @@ -148,6 +150,7 @@ export class Plugin charts: pluginsStart.charts, alerting: pluginsStart.alerting, spaces: pluginsStart.spaces, + spacesOss: pluginsStart.spacesOss, element: params.element, storage: new Storage(window.localStorage), setBreadcrumbs: params.setBreadcrumbs, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index f01967592ea8c..ae4fd5152794f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -24,6 +24,7 @@ import { ActionGroup, AlertActionParam, SanitizedAlert, + ResolvedSanitizedRule, AlertAction, AlertAggregations, AlertTaskState, @@ -40,6 +41,7 @@ import { // In Triggers and Actions we treat all `Alert`s as `SanitizedAlert` // so the `Params` is a black-box of Record type Alert = SanitizedAlert; +type ResolvedRule = ResolvedSanitizedRule; export { Alert, @@ -52,6 +54,7 @@ export { AlertingFrameworkHealth, AlertNotifyWhenType, AlertTypeParams, + ResolvedRule, }; export { ActionType, diff --git a/x-pack/plugins/triggers_actions_ui/tsconfig.json b/x-pack/plugins/triggers_actions_ui/tsconfig.json index 6536206acf369..e3c8b77d2c1d4 100644 --- a/x-pack/plugins/triggers_actions_ui/tsconfig.json +++ b/x-pack/plugins/triggers_actions_ui/tsconfig.json @@ -24,5 +24,6 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/spaces_oss/tsconfig.json" }, ] } diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts index b40388376d8d5..e8782edc829a4 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts @@ -66,7 +66,7 @@ describe('', () => { test('should populate the correct values', () => { const { find, exists, component } = testBed; const { watch } = WATCH; - const codeEditor = component.find('EuiCodeEditor'); + const codeEditor = component.find('EuiCodeEditor').at(1); expect(exists('jsonWatchForm')).toBe(true); expect(find('nameInput').props().value).toBe(watch.name); diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx index 0c1d643475566..b19d97f67d2e0 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_form.tsx @@ -10,7 +10,6 @@ import React, { Fragment, useContext, useState } from 'react'; import { EuiButton, EuiButtonEmpty, - EuiCodeEditor, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -25,7 +24,7 @@ import { XJsonMode } from '@kbn/ace'; import { serializeJsonWatch } from '../../../../../../common/lib/serialization'; import { ErrableFormRow, SectionError, Error as ServerError } from '../../../../components'; -import { XJson } from '../../../../shared_imports'; +import { XJson, EuiCodeEditor } from '../../../../shared_imports'; import { onWatchSave } from '../../watch_edit_actions'; import { WatchContext } from '../../watch_context'; import { goToWatchList } from '../../../../lib/navigation'; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx index fa5ae53b9c6fa..034c0080c852c 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/json_watch_edit/json_watch_edit_simulate.tsx @@ -10,7 +10,6 @@ import React, { Fragment, useContext, useState } from 'react'; import { EuiBasicTable, EuiButton, - EuiCodeEditor, EuiDescribedFormGroup, EuiFieldNumber, EuiFlexGroup, @@ -44,7 +43,7 @@ import { JsonWatchEditSimulateResults } from './json_watch_edit_simulate_results import { getTimeUnitLabel } from '../../../../lib/get_time_unit_label'; import { useAppContext } from '../../../../app_context'; -import { XJson } from '../../../../shared_imports'; +import { XJson, EuiCodeEditor } from '../../../../shared_imports'; const { useXJsonMode } = XJson; diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx index 0199fce195279..bbe5449fa8732 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx @@ -8,7 +8,6 @@ import React, { Fragment, useEffect } from 'react'; import { - EuiCodeEditor, EuiFieldNumber, EuiFieldPassword, EuiFieldText, @@ -19,6 +18,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { EuiCodeEditor } from '../../../../../shared_imports'; import { ErrableFormRow } from '../../../../../components/form_errors'; import { WebhookAction } from '../../../../../../../common/types/action_types'; diff --git a/x-pack/plugins/watcher/public/application/shared_imports.ts b/x-pack/plugins/watcher/public/application/shared_imports.ts index 44bef3b0c4f5f..977204c627e5c 100644 --- a/x-pack/plugins/watcher/public/application/shared_imports.ts +++ b/x-pack/plugins/watcher/public/application/shared_imports.ts @@ -13,4 +13,5 @@ export { useRequest, XJson, PageError, + EuiCodeEditor, } from '../../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index 81b544ac97152..e5852d55e13c6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -6,8 +6,10 @@ */ import expect from '@kbn/expect'; +import type { ApiResponse, estypes } from '@elastic/elasticsearch'; import { getUrlPrefix } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import type { RawAlert } from '../../../../../plugins/alerting/server/types'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -197,5 +199,24 @@ export default function createGetTests({ getService }: FtrProviderContext) { }, ]); }); + + it('7.16.0 migrates existing alerts to contain legacyId field', async () => { + const searchResult: ApiResponse> = await es.search({ + index: '.kibana', + body: { + query: { + term: { + _id: 'alert:74f3e6d7-b7bb-477d-ac28-92ee22728e6e', + }, + }, + }, + }); + expect(searchResult.statusCode).to.equal(200); + expect((searchResult.body.hits.total as estypes.SearchTotalHits).value).to.equal(1); + const hit = searchResult.body.hits.hits[0]; + expect((hit!._source!.alert! as RawAlert).legacyId).to.equal( + '74f3e6d7-b7bb-477d-ac28-92ee22728e6e' + ); + }); }); } diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional/apps/uptime/synthetics_integration.ts index c4996299f0d43..6d468b32d7018 100644 --- a/x-pack/test/functional/apps/uptime/synthetics_integration.ts +++ b/x-pack/test/functional/apps/uptime/synthetics_integration.ts @@ -92,7 +92,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { host, }); - describe('displays custom UI', () => { + // FLAKY: https://github.com/elastic/kibana/issues/109260 + describe.skip('displays custom UI', () => { before(async () => { const version = await uptimeService.syntheticsPackage.getSyntheticsPackageVersion(); await uptimePage.syntheticsIntegration.navigateToPackagePage(version!); 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 2994e18fa9ab7..7bfae9ba36be4 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 @@ -32,6 +32,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/visualize/default'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + // ensure we're logged out so we can login as the appropriate users + await PageObjects.security.forceLogout(); }); after(async () => { @@ -81,7 +83,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it('shows visualize navlink', async () => { const navLinks = (await appsMenu.readLinks()).map((link) => link.text); - expect(navLinks).to.eql(['Overview', 'Visualize Library', 'Stack Management']); + expect(navLinks).to.contain('Visualize Library'); }); it(`landing page shows "Create new Visualization" button`, async () => { diff --git a/x-pack/test/upgrade/apps/maps/maps_smoke_tests.ts b/x-pack/test/upgrade/apps/maps/maps_smoke_tests.ts index 0c43528ad8b84..5a98011386567 100644 --- a/x-pack/test/upgrade/apps/maps/maps_smoke_tests.ts +++ b/x-pack/test/upgrade/apps/maps/maps_smoke_tests.ts @@ -19,6 +19,7 @@ export default function ({ const screenshot = getService('screenshots'); const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); + const browser = getService('browser'); const SAMPLE_DATA_RANGE = `[ { "from": "now-30d", @@ -87,20 +88,29 @@ export default function ({ ]; before(async () => { - await kibanaServer.uiSettings.update({ - [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: SAMPLE_DATA_RANGE, - }); + await kibanaServer.uiSettings.update( + { [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: SAMPLE_DATA_RANGE }, + { space: 'default' } + ); + await kibanaServer.uiSettings.update( + { [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: SAMPLE_DATA_RANGE }, + { space: 'automation' } + ); + await browser.refresh(); }); spaces.forEach(({ space, basePath }) => { describe('space ' + space + ' ecommerce', () => { before(async () => { - await PageObjects.common.navigateToActualUrl('home', '/tutorial_directory/sampleData', { - basePath, - }); + await PageObjects.common.navigateToActualUrl( + 'maps', + 'map/' + '2c9c1f60-1909-11e9-919b-ffe5949a18d2', + { + basePath, + } + ); await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.addSampleDataSet('ecommerce'); - await PageObjects.maps.loadSavedMap('[eCommerce] Orders by Country'); + await PageObjects.maps.waitForLayersToLoad(); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.maps.toggleLayerVisibility('United Kingdom'); await PageObjects.maps.toggleLayerVisibility('France'); @@ -120,15 +130,17 @@ export default function ({ expect(percentDifference.toFixed(3)).to.be.lessThan(0.031); }); }); - describe('space ' + space + ' flights', () => { before(async () => { - await PageObjects.common.navigateToActualUrl('home', '/tutorial_directory/sampleData', { - basePath, - }); + await PageObjects.common.navigateToActualUrl( + 'maps', + 'map/' + '5dd88580-1906-11e9-919b-ffe5949a18d2', + { + basePath, + } + ); await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.addSampleDataSet('flights'); - await PageObjects.maps.loadSavedMap('[Flights] Origin Time Delayed'); + await PageObjects.maps.waitForLayersToLoad(); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); await PageObjects.maps.enterFullScreen(); @@ -144,15 +156,17 @@ export default function ({ expect(percentDifference.toFixed(3)).to.be.lessThan(0.031); }); }); - describe('space ' + space + ' web logs', () => { before(async () => { - await PageObjects.common.navigateToActualUrl('home', '/tutorial_directory/sampleData', { - basePath, - }); + await PageObjects.common.navigateToActualUrl( + 'maps', + 'map/' + 'de71f4f0-1902-11e9-919b-ffe5949a18d2', + { + basePath, + } + ); await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.home.addSampleDataSet('logs'); - await PageObjects.maps.loadSavedMap('[Logs] Total Requests and Bytes'); + await PageObjects.maps.waitForLayersToLoad(); await PageObjects.maps.toggleLayerVisibility('Road map'); await PageObjects.maps.toggleLayerVisibility('Total Requests by Country'); await PageObjects.timePicker.setCommonlyUsedTime('sample_data range'); diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index def2ba279bfff..fb57ccd13afbd 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -2,30 +2,3 @@ # yarn lockfile v1 -"@kbn/interpreter@link:../packages/kbn-interpreter": - version "0.0.0" - uid "" - -"@kbn/optimizer@link:../packages/kbn-optimizer": - version "0.0.0" - uid "" - -"@kbn/plugin-helpers@link:../packages/kbn-plugin-helpers": - version "0.0.0" - uid "" - -"@kbn/storybook@link:../packages/kbn-storybook": - version "0.0.0" - uid "" - -"@kbn/test@link:../packages/kbn-test": - version "0.0.0" - uid "" - -"@kbn/ui-framework@link:../packages/kbn-ui-framework": - version "0.0.0" - uid "" - -"@kbn/ui-shared-deps@link:../packages/kbn-ui-shared-deps": - version "0.0.0" - uid ""