diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a4f2b71da1ff..acfb7307f49c4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,6 +132,9 @@ /x-pack/test/alerting_api_integration @elastic/kibana-alerting-services /x-pack/test/plugin_api_integration/plugins/task_manager @elastic/kibana-alerting-services /x-pack/test/plugin_api_integration/test_suites/task_manager @elastic/kibana-alerting-services +/x-pack/legacy/plugins/triggers_actions_ui/ @elastic/kibana-alerting-services +/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/kibana-alerting-services +/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/ @elastic/kibana-alerting-services # Design **/*.scss @elastic/kibana-design diff --git a/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md new file mode 100644 index 0000000000000..ddbf9aafbd28a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [chromeless](./kibana-plugin-public.appbase.chromeless.md) + +## AppBase.chromeless property + +Hide the UI chrome when the application is mounted. Defaults to `false`. Takes precedence over chrome service visibility settings. + +Signature: + +```typescript +chromeless?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.id.md b/docs/development/core/public/kibana-plugin-public.appbase.id.md index 57daa0c94bdf6..89dd32d296104 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.id.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.id.md @@ -4,6 +4,8 @@ ## AppBase.id property +The unique identifier of the application + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-public.appbase.md b/docs/development/core/public/kibana-plugin-public.appbase.md index a93a195c559b1..eb6d91cb92488 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.md @@ -16,10 +16,14 @@ export interface AppBase | Property | Type | Description | | --- | --- | --- | | [capabilities](./kibana-plugin-public.appbase.capabilities.md) | Partial<Capabilities> | Custom capabilities defined by the app. | +| [chromeless](./kibana-plugin-public.appbase.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | | [icon](./kibana-plugin-public.appbase.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | -| [id](./kibana-plugin-public.appbase.id.md) | string | | +| [id](./kibana-plugin-public.appbase.id.md) | string | The unique identifier of the application | +| [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) | AppNavLinkStatus | The initial status of the application's navLink. Defaulting to visible if status is accessible and hidden if status is inaccessible See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | | [order](./kibana-plugin-public.appbase.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [status](./kibana-plugin-public.appbase.status.md) | AppStatus | The initial status of the application. Defaulting to accessible | | [title](./kibana-plugin-public.appbase.title.md) | string | The title of the application. | -| [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) | Observable<string> | An observable for a tooltip shown when hovering over app link. | +| [tooltip](./kibana-plugin-public.appbase.tooltip.md) | string | A tooltip shown when hovering over app link. | +| [updater$](./kibana-plugin-public.appbase.updater_.md) | Observable<AppUpdater> | An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. | diff --git a/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md new file mode 100644 index 0000000000000..d6744c3e75756 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) + +## AppBase.navLinkStatus property + +The initial status of the application's navLink. Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +Signature: + +```typescript +navLinkStatus?: AppNavLinkStatus; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md b/docs/development/core/public/kibana-plugin-public.appbase.status.md similarity index 56% rename from docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md rename to docs/development/core/public/kibana-plugin-public.appbase.status.md index 0767ead5f1455..a5fbadbeea1ff 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.status.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [status](./kibana-plugin-public.appbase.status.md) -## AppBase.tooltip$ property +## AppBase.status property -An observable for a tooltip shown when hovering over app link. +The initial status of the application. Defaulting to `accessible` Signature: ```typescript -tooltip$?: Observable; +status?: AppStatus; ``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md new file mode 100644 index 0000000000000..85921a5a321dd --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip](./kibana-plugin-public.appbase.tooltip.md) + +## AppBase.tooltip property + +A tooltip shown when hovering over app link. + +Signature: + +```typescript +tooltip?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.updater_.md b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md new file mode 100644 index 0000000000000..3edd357383449 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md @@ -0,0 +1,44 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [updater$](./kibana-plugin-public.appbase.updater_.md) + +## AppBase.updater$ property + +An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. + +Signature: + +```typescript +updater$?: Observable; +``` + +## Example + +How to update an application navLink at runtime + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + }) + } + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.md index a63de399c2ecb..cf9bc5189af40 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.md @@ -16,5 +16,6 @@ export interface ApplicationSetup | Method | Description | | --- | --- | | [register(app)](./kibana-plugin-public.applicationsetup.register.md) | Register an mountable application to the system. | +| [registerAppUpdater(appUpdater$)](./kibana-plugin-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | | [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md new file mode 100644 index 0000000000000..39b4f878a3f79 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md @@ -0,0 +1,47 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerAppUpdater](./kibana-plugin-public.applicationsetup.registerappupdater.md) + +## ApplicationSetup.registerAppUpdater() method + +Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime. + +This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the `updater$` property of the registered application instead. + +Signature: + +```typescript +registerAppUpdater(appUpdater$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appUpdater$ | Observable<AppUpdater> | | + +Returns: + +`void` + +## Example + +How to register an application updater that disables some applications: + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + setup({ application }) { + application.registerAppUpdater( + new BehaviorSubject(app => { + if (myPluginApi.shouldDisable(app)) + return { + status: AppStatus.inaccessible, + }; + }) + ); + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md new file mode 100644 index 0000000000000..d6b22ac2b9217 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +## AppNavLinkStatus enum + +Status of the application's navLink. + +Signature: + +```typescript +export declare enum AppNavLinkStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| default | 0 | The application navLink will be visible if the application's [AppStatus](./kibana-plugin-public.appstatus.md) is set to accessible and hidden if the application status is set to inaccessible. | +| disabled | 2 | The application navLink is visible but inactive and not clickable in the navigation bar. | +| hidden | 3 | The application navLink does not appear in the navigation bar. | +| visible | 1 | The application navLink is visible and clickable in the navigation bar. | + diff --git a/docs/development/core/public/kibana-plugin-public.appstatus.md b/docs/development/core/public/kibana-plugin-public.appstatus.md new file mode 100644 index 0000000000000..23fb7186569da --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appstatus.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppStatus](./kibana-plugin-public.appstatus.md) + +## AppStatus enum + +Accessibility status of an application. + +Signature: + +```typescript +export declare enum AppStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| accessible | 0 | Application is accessible. | +| inaccessible | 1 | Application is not accessible. | + diff --git a/docs/development/core/public/kibana-plugin-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md new file mode 100644 index 0000000000000..b9260c79cd972 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) + +## AppUpdatableFields type + +Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). + +Signature: + +```typescript +export declare type AppUpdatableFields = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appupdater.md b/docs/development/core/public/kibana-plugin-public.appupdater.md new file mode 100644 index 0000000000000..f1b965cc2fc22 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdater.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdater](./kibana-plugin-public.appupdater.md) + +## AppUpdater type + +Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) + +Signature: + +```typescript +export declare type AppUpdater = (app: AppBase) => Partial | undefined; +``` diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index f03f3457ca93f..64cbdd880fed1 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -1,147 +1,151 @@ - - -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) - -## kibana-plugin-public package - -The Kibana Core APIs for client-side plugins. - -A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). - -The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. - -## Classes - -| Class | Description | -| --- | --- | -| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | -| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | -| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | - -## Enumerations - -| Enumeration | Description | -| --- | --- | -| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | - -## Interfaces - -| Interface | Description | -| --- | --- | -| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | -| [AppBase](./kibana-plugin-public.appbase.md) | | -| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | -| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | -| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | -| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | -| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | -| [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | -| [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | -| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | -| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | -| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | -| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | -| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | -| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | -| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | -| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | -| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | -| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | -| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | -| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | -| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | -| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | -| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | -| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | -| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | -| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | -| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | -| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | -| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | -| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | -| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | -| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | -| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | -| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | -| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | -| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | -| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | -| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | -| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | -| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | -| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | -| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | -| [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | -| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | -| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | -| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | -| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | -| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | -| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | -| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | -| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | -| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | -| [SavedObject](./kibana-plugin-public.savedobject.md) | | -| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | -| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | -| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | -| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | -| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | -| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | -| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | -| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | -| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | -| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | -| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | -| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | -| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | -| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | -| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | -| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | -| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | -| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | -| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | -| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | -| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | -| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | - -## Type Aliases - -| Type Alias | Description | -| --- | --- | -| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | -| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | -| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | -| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | -| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | -| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | -| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | -| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | -| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | -| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | -| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | -| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | -| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | -| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | -| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | -| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | -| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | -| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | -| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | -| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | -| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | -| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | -| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | -| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | -| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | -| [Toast](./kibana-plugin-public.toast.md) | | -| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | -| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | - + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) + +## kibana-plugin-public package + +The Kibana Core APIs for client-side plugins. + +A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). + +The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. + +## Classes + +| Class | Description | +| --- | --- | +| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | +| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | +| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | + +## Enumerations + +| Enumeration | Description | +| --- | --- | +| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | +| [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | Status of the application's navLink. | +| [AppStatus](./kibana-plugin-public.appstatus.md) | Accessibility status of an application. | + +## Interfaces + +| Interface | Description | +| --- | --- | +| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | +| [AppBase](./kibana-plugin-public.appbase.md) | | +| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | +| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | +| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | +| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | +| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | +| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | +| [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | +| [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | +| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | +| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | +| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | +| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | +| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | +| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | +| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | +| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | +| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | +| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | +| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | +| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | +| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | +| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | +| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | +| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | +| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | +| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | +| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | +| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | +| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | +| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | +| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | +| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | +| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | +| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | +| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | +| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | +| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | +| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | +| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | +| [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | +| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | +| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | +| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | +| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | +| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | +| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | +| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | +| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | +| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | +| [SavedObject](./kibana-plugin-public.savedobject.md) | | +| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | +| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | +| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | +| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | +| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | +| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | +| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | +| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | +| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | +| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | +| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | +| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | +| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | +| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | +| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | +| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | +| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | +| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | + +## Type Aliases + +| Type Alias | Description | +| --- | --- | +| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | +| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | +| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | +| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | +| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | +| [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). | +| [AppUpdater](./kibana-plugin-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | +| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | +| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | +| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | +| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | +| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | +| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | +| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | +| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | +| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | +| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | +| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | +| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | +| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | +| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | +| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | +| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | +| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | +| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | +| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | +| [Toast](./kibana-plugin-public.toast.md) | | +| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | +| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | + diff --git a/docs/development/core/server/kibana-plugin-server.basepath.get.md b/docs/development/core/server/kibana-plugin-server.basepath.get.md index 6ef7022f10e62..a20bc1a4e3174 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.get.md +++ b/docs/development/core/server/kibana-plugin-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: LegacyRequest | KibanaRequest) => string; ``` diff --git a/docs/development/core/server/kibana-plugin-server.basepath.md b/docs/development/core/server/kibana-plugin-server.basepath.md index 50a30f7c43fe6..63aeb7f711d97 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.md @@ -20,9 +20,9 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | -| [get](./kibana-plugin-server.basepath.get.md) | | (request: KibanaRequest<unknown, unknown, unknown, any> | LegacyRequest) => string | returns basePath value, specific for an incoming request. | +| [get](./kibana-plugin-server.basepath.get.md) | | (request: LegacyRequest | KibanaRequest<unknown, unknown, unknown, any>) => string | returns basePath value, specific for an incoming request. | | [prepend](./kibana-plugin-server.basepath.prepend.md) | | (path: string) => string | Prepends path with the basePath. | | [remove](./kibana-plugin-server.basepath.remove.md) | | (path: string) => string | Removes the prepended basePath from the path. | | [serverBasePath](./kibana-plugin-server.basepath.serverbasepath.md) | | string | returns the server's basePathSee [BasePath.get](./kibana-plugin-server.basepath.get.md) for getting the basePath value for a specific request | -| [set](./kibana-plugin-server.basepath.set.md) | | (request: KibanaRequest<unknown, unknown, unknown, any> | LegacyRequest, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | +| [set](./kibana-plugin-server.basepath.set.md) | | (request: LegacyRequest | KibanaRequest<unknown, unknown, unknown, any>, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | diff --git a/docs/development/core/server/kibana-plugin-server.basepath.set.md b/docs/development/core/server/kibana-plugin-server.basepath.set.md index 56a7f644d34cc..ac08baa0bb99e 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.set.md +++ b/docs/development/core/server/kibana-plugin-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: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void; ``` diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md new file mode 100644 index 0000000000000..7ad26b85bf81c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [UiSettingsParams](./kibana-plugin-server.uisettingsparams.md) > [deprecation](./kibana-plugin-server.uisettingsparams.deprecation.md) + +## UiSettingsParams.deprecation property + +optional deprecation information. Used to generate a deprecation warning. + +Signature: + +```typescript +deprecation?: DeprecationSettings; +``` diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md index a38499e8f37dd..fc2f8038f973f 100644 --- a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md @@ -17,6 +17,7 @@ export interface UiSettingsParams | Property | Type | Description | | --- | --- | --- | | [category](./kibana-plugin-server.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | +| [deprecation](./kibana-plugin-server.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-server.uisettingsparams.description.md) | string | description provided to a user in UI | | [name](./kibana-plugin-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | @@ -24,5 +25,6 @@ export interface UiSettingsParams | [readonly](./kibana-plugin-server.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-server.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [type](./kibana-plugin-server.uisettingsparams.type.md) | UiSettingsType | defines a type of UI element [UiSettingsType](./kibana-plugin-server.uisettingstype.md) | +| [validation](./kibana-plugin-server.uisettingsparams.validation.md) | ImageValidation | StringValidation | | | [value](./kibana-plugin-server.uisettingsparams.value.md) | SavedObjectAttribute | default value to fall back to if a user doesn't provide any | diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md new file mode 100644 index 0000000000000..f097f36e999ba --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [UiSettingsParams](./kibana-plugin-server.uisettingsparams.md) > [validation](./kibana-plugin-server.uisettingsparams.validation.md) + +## UiSettingsParams.validation property + +Signature: + +```typescript +validation?: ImageValidation | StringValidation; +``` diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 977a65f62202d..757c6f10f2a99 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -187,7 +187,8 @@ Refresh the page to apply the changes. === Search settings [horizontal] -`courier:batchSearches`:: When disabled, dashboard panels will load individually, and search requests will terminate when +`courier:batchSearches`:: **Deprecated in 7.6. Starting in 8.0, this setting will be optimized internally.** +When disabled, dashboard panels will load individually, and search requests will terminate when users navigate away or update the query. When enabled, dashboard panels will load together when all of the data is loaded, and searches will not terminate. `courier:customRequestPreference`:: {ref}/search-request-body.html#request-body-search-preference[Request preference] diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 38a46a3cde5a0..8f445ff25218b 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -7,10 +7,10 @@ By default, the Monitoring application is enabled, but data collection is disabled. When you first start {kib} monitoring, you are prompted to -enable data collection. If you are using {security}, you must be +enable data collection. If you are using {security}, you must be signed in as a user with the `cluster:manage` privilege to enable data collection. The built-in `superuser` role has this privilege and the -built-in `elastic` user has this role. +built-in `elastic` user has this role. You can adjust how monitoring data is collected from {kib} and displayed in {kib} by configuring settings in the @@ -134,3 +134,11 @@ For {es} clusters that are running in containers, this setting changes the statistics. It also adds the calculated Cgroup CPU utilization to the *Node Overview* page instead of the overall operating system's CPU utilization. Defaults to `false`. + +`xpack.monitoring.ui.container.logstash.enabled`:: + +For {ls} nodes that are running in containers, this setting +changes the {ls} *Node Listing* to display the CPU utilization +based on the reported Cgroup statistics. It also adds the +calculated Cgroup CPU utilization to the {ls} node detail +pages instead of the overall operating system’s CPU utilization. Defaults to `false`. diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index fed4ba4886bf9..eef2b11e53d85 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -4,7 +4,8 @@ * <> * <> * <> -* <> +* <> +* <> * <> * <> @@ -18,7 +19,7 @@ While Kibana isn't terribly resource intensive, we still recommend running Kiban separate from your Elasticsearch data or master nodes. To distribute Kibana traffic across the nodes in your Elasticsearch cluster, you can run Kibana and an Elasticsearch client node on the same machine. For more information, see -<>. +<>. [float] [[configuring-kibana-shield]] @@ -63,7 +64,7 @@ csp.strict: true See <>. [float] -[[load-balancing]] +[[load-balancing-es]] === Load Balancing Across Multiple Elasticsearch Nodes If you have multiple nodes in your Elasticsearch cluster, the easiest way to distribute Kibana requests across the nodes is to run an Elasticsearch _Coordinating only_ node on the same machine as Kibana. @@ -110,9 +111,40 @@ transport.tcp.port: 9300 - 9400 elasticsearch.hosts: ["http://localhost:9200"] -------- +[float] +[[load-balancing-kibana]] +=== Load balancing across multiple Kibana instances +To serve multiple Kibana installations behind a load balancer, you must change the configuration. See {kibana-ref}/settings.html[Configuring Kibana] for details on each setting. + +Settings unique across each Kibana instance: +-------- +server.uuid +server.name +-------- + +Settings unique across each host (for example, running multiple installations on the same virtual machine): +-------- +logging.dest +path.data +pid.file +server.port +-------- + +Settings that must be the same: +-------- +xpack.security.encryptionKey //decrypting session cookies +xpack.reporting.encryptionKey //decrypting reports stored in Elasticsearch +-------- + +Separate configuration files can be used from the command line by using the `-c` flag: +-------- +bin/kibana -c config/instance1.yml +bin/kibana -c config/instance2.yml +-------- + [float] [[high-availability]] -=== High Availability Across Multiple Elasticsearch Nodes +=== High availability across multiple Elasticsearch nodes Kibana can be configured to connect to multiple Elasticsearch nodes in the same cluster. In situations where a node becomes unavailable, Kibana will transparently connect to an available node and continue operating. Requests to available hosts will be routed in a round robin fashion. diff --git a/docs/user/security/reporting.asciidoc b/docs/user/security/reporting.asciidoc index c2ed295e83ce9..5f5d85fe8d3be 100644 --- a/docs/user/security/reporting.asciidoc +++ b/docs/user/security/reporting.asciidoc @@ -125,23 +125,33 @@ the {reporting} endpoints to authorized users. This requires that you: . Enable {security} on your {es} cluster. For more information, see {ref}/security-getting-started.html[Getting Started with Security]. -. Configure an SSL certificate for Kibana. For more information, see -<>. -. Configure {watcher} to trust the Kibana server's certificate by adding it to -the {watcher} truststore on each node: -.. Import the {kib} server certificate into the {watcher} truststore using -Java Keytool: +. Configure TLS/SSL encryption for the {kib} server. For more information, see +<>. +. Specify the {kib} server's CA certificate chain in `elasticsearch.yml`: + -[source,shell] ---------------------------------------------------------- -keytool -importcert -keystore watcher-truststore.jks -file server.crt ---------------------------------------------------------- -+ -NOTE: If the truststore doesn't already exist, it is created. +-- +If you are using your own CA to sign the {kib} server certificate, then you need +to specify the CA certificate chain in {es} to properly establish trust in TLS +connections between {watcher} and {kib}. If your CA certificate chain is +contained in a PKCS #12 trust store, specify it like so: + +[source,yaml] +-------------------------------------------------------------------------------- +xpack.http.ssl.truststore.path: "/path/to/your/truststore.p12" +xpack.http.ssl.truststore.type: "PKCS12" +xpack.http.ssl.truststore.password: "optional decryption password" +-------------------------------------------------------------------------------- + +Otherwise, if your CA certificate chain is in PEM format, specify it like so: + +[source,yaml] +-------------------------------------------------------------------------------- +xpack.http.ssl.certificate_authorities: ["/path/to/your/cacert1.pem", "/path/to/your/cacert2.pem"] +-------------------------------------------------------------------------------- + +For more information, see {ref}/notification-settings.html#ssl-notification-settings[the {watcher} HTTP TLS/SSL Settings]. +-- -.. Make sure the `xpack.http.ssl.truststore.path` setting in -`elasticsearch.yml` specifies the location of the {watcher} -truststore. . Add one or more users who have the permissions necessary to use {kib} and {reporting}. For more information, see <>. diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx index 84defb4a91e3f..84f64f99d0179 100644 --- a/examples/state_containers_examples/public/todo.tsx +++ b/examples/state_containers_examples/public/todo.tsx @@ -41,6 +41,7 @@ import { PureTransition, syncStates, getStateFromKbnUrl, + BaseState, } from '../../../src/plugins/kibana_utils/public'; import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; import { @@ -79,7 +80,7 @@ const TodoApp: React.FC = ({ filter }) => { const { setText } = GlobalStateHelpers.useTransitions(); const { text } = GlobalStateHelpers.useState(); const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); - const todos = useState(); + const todos = useState().todos; const filteredTodos = todos.filter(todo => { if (!filter) return true; if (filter === 'completed') return todo.completed; @@ -306,7 +307,7 @@ export const TodoAppPage: React.FC<{ ); }; -function withDefaultState( +function withDefaultState( stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow defaultState: State @@ -314,14 +315,10 @@ function withDefaultState( return { ...stateContainer, set: (state: State | null) => { - if (Array.isArray(defaultState)) { - stateContainer.set(state || defaultState); - } else { - stateContainer.set({ - ...defaultState, - ...state, - }); - } + stateContainer.set({ + ...defaultState, + ...state, + }); }, }; } diff --git a/package.json b/package.json index 0ed74dd65d1ab..a623b656ec9a1 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "@elastic/charts": "^16.1.0", "@elastic/datemath": "5.0.2", "@elastic/ems-client": "1.0.5", - "@elastic/eui": "17.3.1", + "@elastic/eui": "18.0.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "2.3.3", @@ -163,9 +163,9 @@ "compare-versions": "3.5.1", "core-js": "^3.2.1", "css-loader": "2.1.1", - "custom-event-polyfill": "^0.3.0", "d3": "3.5.17", "d3-cloud": "1.2.5", + "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.2.0", @@ -253,6 +253,7 @@ "rison-node": "1.0.2", "rxjs": "^6.5.3", "script-loader": "0.7.2", + "seedrandom": "^3.0.5", "semver": "^5.5.0", "style-it": "^2.1.3", "style-loader": "0.23.1", @@ -314,6 +315,7 @@ "@types/classnames": "^2.2.9", "@types/d3": "^3.5.43", "@types/dedent": "^0.7.0", + "@types/deep-freeze-strict": "^1.1.0", "@types/delete-empty": "^2.0.0", "@types/elasticsearch": "^5.0.33", "@types/enzyme": "^3.9.0", @@ -363,7 +365,7 @@ "@types/semver": "^5.5.0", "@types/sinon": "^7.0.13", "@types/strip-ansi": "^3.0.0", - "@types/styled-components": "^4.4.1", + "@types/styled-components": "^4.4.2", "@types/supertest": "^2.0.5", "@types/supertest-as-promised": "^2.0.38", "@types/testing-library__react": "^9.1.2", diff --git a/packages/kbn-spec-to-console/lib/convert.js b/packages/kbn-spec-to-console/lib/convert.js index 4c31281860767..5dbdd6e1c94e4 100644 --- a/packages/kbn-spec-to-console/lib/convert.js +++ b/packages/kbn-spec-to-console/lib/convert.js @@ -24,10 +24,16 @@ const convertParts = require('./convert/parts'); module.exports = spec => { const result = {}; - // TODO: - // Since https://github.com/elastic/elasticsearch/pull/42346 has been merged into ES master - // the JSON doc specification has been updated. We need to update this script to take advantage - // of the added information but it will also require updating console's editor autocomplete. + /** + * TODO: + * Since https://github.com/elastic/elasticsearch/pull/42346 has been merged into ES master + * the JSON doc specification has been updated. We need to update this script to take advantage + * of the added information but it will also require updating console editor autocomplete. + * + * Note: for now we exclude all deprecated patterns from the generated spec to prevent them + * from being used in autocompletion. It would be really nice if we could use this information + * instead of just not including it. + */ Object.keys(spec).forEach(api => { const source = spec[api]; if (!source.url) { @@ -46,8 +52,10 @@ module.exports = spec => { const urlComponents = {}; if (source.url.paths) { - patterns = convertPaths(source.url.paths); - source.url.paths.forEach(pathsObject => { + // We filter out all deprecated url patterns here. + const paths = source.url.paths.filter(path => !path.deprecated); + patterns = convertPaths(paths); + paths.forEach(pathsObject => { pathsObject.methods.forEach(method => methodSet.add(method)); if (pathsObject.parts) { for (const partName of Object.keys(pathsObject.parts)) { diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js index 250abd162f91d..7a15c3bb742c0 100644 --- a/packages/kbn-ui-shared-deps/entry.js +++ b/packages/kbn-ui-shared-deps/entry.js @@ -17,6 +17,9 @@ * under the License. */ +// import global polyfills before everything else +require('./polyfills'); + // must load before angular export const Jquery = require('jquery'); window.$ = window.jQuery = Jquery; diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index 014467d204d96..c9434f3ec1c38 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,12 +9,15 @@ "kbn:watch": "node scripts/build --watch" }, "devDependencies": { - "@elastic/eui": "17.3.1", + "@elastic/eui": "18.0.0", "@elastic/charts": "^16.1.0", "@kbn/dev-utils": "1.0.0", "@yarnpkg/lockfile": "^1.1.0", + "abortcontroller-polyfill": "^1.3.0", "angular": "^1.7.9", + "core-js": "^3.2.1", "css-loader": "^2.1.1", + "custom-event-polyfill": "^0.3.0", "del": "^5.1.0", "jquery": "^3.4.1", "mini-css-extract-plugin": "0.8.0", @@ -24,6 +27,9 @@ "react-intl": "^2.8.0", "react": "^16.12.0", "read-pkg": "^5.2.0", - "webpack": "4.41.0" + "regenerator-runtime": "^0.13.3", + "symbol-observable": "^1.2.0", + "webpack": "4.41.0", + "whatwg-fetch": "^3.0.0" } } \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/polyfills.js b/packages/kbn-ui-shared-deps/polyfills.js new file mode 100644 index 0000000000000..d2305d643e4d2 --- /dev/null +++ b/packages/kbn-ui-shared-deps/polyfills.js @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +require('core-js/stable'); +require('regenerator-runtime/runtime'); +require('custom-event-polyfill'); +require('whatwg-fetch'); +require('abortcontroller-polyfill/dist/polyfill-patch-fetch'); +require('./vendor/childnode_remove_polyfill'); +require('symbol-observable'); diff --git a/webpackShims/childnode-remove-polyfill.js b/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js similarity index 68% rename from webpackShims/childnode-remove-polyfill.js rename to packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js index 26c21d1674b07..d8818fe809ccb 100644 --- a/webpackShims/childnode-remove-polyfill.js +++ b/packages/kbn-ui-shared-deps/vendor/childnode_remove_polyfill.js @@ -1,21 +1,4 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ +/* eslint-disable @kbn/eslint/require-license-header */ /* @notice * This product bundles childnode-remove which is available under a diff --git a/renovate.json5 b/renovate.json5 index 560403046b0a5..7f67fae894110 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -210,6 +210,14 @@ '@types/dedent', ], }, + { + groupSlug: 'deep-freeze-strict', + groupName: 'deep-freeze-strict related packages', + packageNames: [ + 'deep-freeze-strict', + '@types/deep-freeze-strict', + ], + }, { groupSlug: 'delete-empty', groupName: 'delete-empty related packages', diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index b70ac610f24a7..173d73ffab664 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -55,6 +55,7 @@ - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - [On the server side](#on-the-server-side) - [On the client side](#on-the-client-side) + - [Updates an application navlink at runtime](#updates-an-app-navlink-at-runtime) Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. @@ -1624,3 +1625,31 @@ class MyPlugin { It's not currently possible to use a similar pattern on the client-side. Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. + +### Updates an application navlink at runtime + +The application API now provides a way to updates some of a registered application's properties after registration. + +```typescript +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'Application disabled', + }) + } +``` \ No newline at end of file diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b2e2161c92cc8..dee47315fc322 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { @@ -25,17 +25,21 @@ import { InternalApplicationStart, ApplicationStart, InternalApplicationSetup, + App, + LegacyApp, } from './types'; import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); const createInternalSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), registerLegacyApp: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); @@ -50,8 +54,7 @@ const createInternalStartContractMock = (): jest.Mocked(); return { - availableApps: new Map(), - availableLegacyApps: new Map(), + applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), getComponent: jest.fn(), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 1132abc11703f..4672a42c9eb06 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -18,8 +18,8 @@ */ import { createElement } from 'react'; -import { Subject } from 'rxjs'; -import { bufferCount, skip, takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { bufferCount, skip, take, takeUntil } from 'rxjs/operators'; import { shallow } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; @@ -29,8 +29,25 @@ import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; - -function mount() {} +import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; + +const createApp = (props: Partial): App => { + return { + id: 'some-id', + title: 'some-title', + mount: () => () => undefined, + ...props, + }; +}; + +const createLegacyApp = (props: Partial): LegacyApp => { + return { + id: 'some-id', + title: 'some-title', + appUrl: '/my-url', + ...props, + }; +}; let setupDeps: MockLifecycle<'setup'>; let startDeps: MockLifecycle<'start'>; @@ -53,9 +70,9 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the id \\"app1\\""` ); @@ -66,37 +83,91 @@ describe('#setup()', () => { await service.start(startDeps); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); + it('allows to register a statusUpdater for the application', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject(app => ({})); + setup.register(pluginId, createApp({ id: 'app1', updater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + const { applications$ } = await service.start(startDeps); + + let applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + + updater$.next(app => ({ + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + })); + + applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + }); + it('throws an error if an App with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1' }))).toThrow(); - register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any); + register(Symbol(), createApp({ id: 'app-next', appRoute: '/app/app3' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app3' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app3\\""` ); - expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app3' }))).not.toThrow(); }); it('throws an error if an App starts with the HTTP base path', () => { const { register } = service.setup(setupDeps); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/test/app2' })) ).toThrowErrorMatchingInlineSnapshot( `"Cannot register an application route that includes HTTP base path"` ); @@ -107,9 +178,11 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app2' } as any); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` + registerLegacyApp(createLegacyApp({ id: 'app2' })); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app2\\""` ); }); @@ -117,22 +190,228 @@ describe('#setup()', () => { const { registerLegacyApp } = service.setup(setupDeps); await service.start(startDeps); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"Applications cannot be registered after \\"setup\\""` - ); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); it('throws an error if a LegacyApp with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app1' } as any); + registerLegacyApp(createLegacyApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1:other' }))).not.toThrow(); + }); + }); + + describe('registerAppStatusUpdater', () => { + it('updates status fields', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.register(pluginId, createApp({ id: 'app2' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'App inaccessible due to reason', + }; + } + return { + tooltip: 'App accessible', + }; + }) + ); + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + tooltip: 'App accessible', + }) + ); + }); + + it(`properly combine with application's updater$`, async () => { + const setup = service.setup(setupDeps); + const pluginId = Symbol('plugin'); + const appStatusUpdater$ = new BehaviorSubject(app => ({ + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + })); + setup.register(pluginId, createApp({ id: 'app1', updater$: appStatusUpdater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.accessible, + tooltip: 'App inaccessible due to reason', + }; + } + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }) + ); + + const { applications$ } = await service.start(startDeps); + const applications = await applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('applies the most restrictive status in case of multiple updaters', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }) + ); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + }) + ); + }); + + it('emits on applications$ when a status updater changes', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + + const statusUpdater = new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }); + setup.registerAppUpdater(statusUpdater); + + const start = await service.start(startDeps); + let latestValue: ReadonlyMap = new Map(); + start.applications$.subscribe(apps => { + latestValue = apps; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }) + ); + + statusUpdater.next(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('also updates legacy apps', async () => { + const setup = service.setup(setupDeps); + + setup.registerLegacyApp(createLegacyApp({ id: 'app1' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: true, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }) + ); }); }); @@ -141,7 +420,8 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - registerMountContext(pluginId, 'test' as any, mount as any); + const mount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, mount); expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); }); }); @@ -171,35 +451,40 @@ describe('#start()', () => { setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'app2' } as any); - - const { availableApps, availableLegacyApps } = await service.start(startDeps); - - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'app2' })); + + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); + + expect(availableApps.size).toEqual(2); + expect([...availableApps.keys()]).toEqual(['app1', 'app2']); + expect(availableApps.get('app1')).toEqual( + expect.objectContaining({ + appRoute: '/app/app1', + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(availableApps.get('app2')).toEqual( + expect.objectContaining({ + appUrl: '/my-url', + id: 'app2', + legacy: true, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); }); it('passes appIds to capabilities', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - register(Symbol(), { id: 'app2', mount } as any); - register(Symbol(), { id: 'app3', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); + register(Symbol(), createApp({ id: 'app2' })); + register(Symbol(), createApp({ id: 'app3' })); await service.start(startDeps); expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ @@ -222,29 +507,15 @@ describe('#start()', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount } as any); - registerLegacyApp({ id: 'legacyApp2' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp2' })); - const { availableApps, availableLegacyApps } = await service.start(startDeps); + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "legacyApp1" => Object { - "id": "legacyApp1", - }, - } - `); + expect([...availableApps.keys()]).toEqual(['app1', 'legacyApp1']); }); describe('getComponent', () => { @@ -290,9 +561,9 @@ describe('#start()', () => { it('creates URL for registered appId', async () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { getUrlForApp } = await service.start(startDeps); @@ -329,7 +600,7 @@ describe('#start()', () => { it('changes the browser history for custom appRoutes', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -343,7 +614,7 @@ describe('#start()', () => { it('appends a path if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -363,7 +634,7 @@ describe('#start()', () => { it('includes state if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -429,7 +700,7 @@ describe('#start()', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + registerLegacyApp(createLegacyApp({ id: 'baseApp:legacyApp1' })); const { navigateToApp } = await service.start(startDeps); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 5b464737ffe07..c69b96274aa95 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -18,8 +18,8 @@ */ import React from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; import { InjectedMetadataSetup } from '../injected_metadata'; @@ -27,18 +27,23 @@ import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; import { ContextSetup, IContextContainer } from '../context'; import { AppRouter } from './ui'; -import { CapabilitiesService, Capabilities } from './capabilities'; +import { Capabilities, CapabilitiesService } from './capabilities'; import { App, + AppBase, AppLeaveHandler, - LegacyApp, AppMount, AppMountDeprecated, AppMounter, - LegacyAppMounter, - Mounter, + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, + AppUpdater, InternalApplicationSetup, InternalApplicationStart, + LegacyApp, + LegacyAppMounter, + Mounter, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; @@ -62,12 +67,13 @@ interface StartDeps { // Mount functions with two arguments are assumed to expect deprecated `context` object. const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => mount.length === 2; -const filterAvailable = (map: Map, capabilities: Capabilities) => - new Map( - [...map].filter( +function filterAvailable(m: Map, capabilities: Capabilities) { + return new Map( + [...m].filter( ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true ) ); +} const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); const getAppUrl = (mounters: Map, appId: string, path: string = '') => @@ -75,17 +81,25 @@ const getAppUrl = (mounters: Map, appId: string, path: string = .replace(/\/{2,}/g, '/') // Remove duplicate slashes .replace(/\/$/, ''); // Remove trailing slash +const allApplicationsFilter = '__ALL__'; + +interface AppUpdaterWrapper { + application: string; + updater: AppUpdater; +} + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps = new Map(); - private readonly legacyApps = new Map(); + private readonly apps = new Map(); private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); private readonly appLeaveHandlers = new Map(); private currentAppId$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); + private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); private registrationClosed = false; private history?: History; @@ -109,8 +123,22 @@ export class ApplicationService { this.navigate = (url, state) => // basePath not needed here because `history` is configured with basename this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); + this.mountContext = context.createContextContainer(); + const registerStatusUpdater = (application: string, updater$: Observable) => { + const updaterId = Symbol(); + const subscription = updater$.subscribe(updater => { + const nextValue = new Map(this.statusUpdaters$.getValue()); + nextValue.set(updaterId, { + application, + updater, + }); + this.statusUpdaters$.next(nextValue); + }); + this.subscriptions.push(subscription); + }; + return { registerMountContext: this.mountContext!.registerContext, register: (plugin, app) => { @@ -145,7 +173,17 @@ export class ApplicationService { this.currentAppId$.next(app.id); return unmount; }; - this.apps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: false, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), @@ -158,15 +196,25 @@ export class ApplicationService { if (this.registrationClosed) { throw new Error('Applications cannot be registered after "setup"'); - } else if (this.legacyApps.has(app.id)) { - throw new Error(`A legacy application is already registered with the id "${app.id}"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); } else if (basename && appRoute!.startsWith(basename)) { throw new Error('Cannot register an application route that includes HTTP base path'); } const appBasePath = basePath.prepend(appRoute); const mount: LegacyAppMounter = () => redirectTo(appBasePath); - this.legacyApps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: true, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute, appBasePath, @@ -174,6 +222,8 @@ export class ApplicationService { unmountBeforeMounting: true, }); }, + registerAppUpdater: (appUpdater$: Observable) => + registerStatusUpdater(allApplicationsFilter, appUpdater$), }; } @@ -190,16 +240,35 @@ export class ApplicationService { http, }); const availableMounters = filterAvailable(this.mounters, capabilities); + const availableApps = filterAvailable(this.apps, capabilities); + + const applications$ = new BehaviorSubject(availableApps); + this.statusUpdaters$ + .pipe( + map(statusUpdaters => { + return new Map( + [...availableApps].map(([id, app]) => [ + id, + updateStatus(app, [...statusUpdaters.values()]), + ]) + ); + }) + ) + .subscribe(apps => applications$.next(apps)); return { - availableApps: filterAvailable(this.apps, capabilities), - availableLegacyApps: filterAvailable(this.legacyApps, capabilities), + applications$, capabilities, currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)), registerMountContext: this.mountContext.registerContext, getUrlForApp: (appId, { path }: { path?: string } = {}) => getAppUrl(availableMounters, appId, path), navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => { + const app = applications$.value.get(appId); + if (app && app.status !== AppStatus.accessible) { + // should probably redirect to the error page instead + throw new Error(`Trying to navigate to an inaccessible application: ${appId}`); + } if (await this.shouldNavigate(overlays)) { this.appLeaveHandlers.delete(this.currentAppId$.value!); this.navigate!(getAppUrl(availableMounters, appId, path), state); @@ -259,6 +328,32 @@ export class ApplicationService { public stop() { this.stop$.next(); this.currentAppId$.complete(); + this.statusUpdaters$.complete(); + this.subscriptions.forEach(sub => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); } } + +const updateStatus = (app: T, statusUpdaters: AppUpdaterWrapper[]): T => { + let changes: Partial = {}; + statusUpdaters.forEach(wrapper => { + if (wrapper.application !== allApplicationsFilter && wrapper.application !== app.id) { + return; + } + const fields = wrapper.updater(app); + if (fields) { + changes = { + ...changes, + ...fields, + // status and navLinkStatus enums are ordered by reversed priority + // if multiple updaters wants to change these fields, we will always follow the priority order. + status: Math.max(changes.status ?? 0, fields.status ?? 0), + navLinkStatus: Math.max(changes.navLinkStatus ?? 0, fields.navLinkStatus ?? 0), + }; + } + }); + return { + ...app, + ...changes, + }; +}; diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 17fec9261accf..e7ea330657648 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -27,6 +27,10 @@ export { AppUnmount, AppMountContext, AppMountParameters, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, ApplicationSetup, ApplicationStart, AppLeaveHandler, diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 4caf236979c37..0d955482d2226 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -34,6 +34,9 @@ import { SavedObjectsStart } from '../saved_objects'; /** @public */ export interface AppBase { + /** + * The unique identifier of the application + */ id: string; /** @@ -41,15 +44,62 @@ export interface AppBase { */ title: string; + /** + * The initial status of the application. + * Defaulting to `accessible` + */ + status?: AppStatus; + + /** + * The initial status of the application's navLink. + * Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` + * See {@link AppNavLinkStatus} + */ + navLinkStatus?: AppNavLinkStatus; + + /** + * An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime. + * + * @example + * + * How to update an application navLink at runtime + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * private appUpdater = new BehaviorSubject(() => ({})); + * + * setup({ application }) { + * application.register({ + * id: 'my-app', + * title: 'My App', + * updater$: this.appUpdater, + * async mount(params) { + * const { renderApp } = await import('./application'); + * return renderApp(params); + * }, + * }); + * } + * + * start() { + * // later, when the navlink needs to be updated + * appUpdater.next(() => { + * navLinkStatus: AppNavLinkStatus.disabled, + * }) + * } + * ``` + */ + updater$?: Observable; + /** * An ordinal used to sort nav links relative to one another for display. */ order?: number; /** - * An observable for a tooltip shown when hovering over app link. + * A tooltip shown when hovering over app link. */ - tooltip$?: Observable; + tooltip?: string; /** * A EUI iconType that will be used for the app's icon. This icon @@ -67,8 +117,76 @@ export interface AppBase { * Custom capabilities defined by the app. */ capabilities?: Partial; + + /** + * Flag to keep track of legacy applications. + * For internal use only. any value will be overridden when registering an App. + * + * @internal + */ + legacy?: boolean; + + /** + * Hide the UI chrome when the application is mounted. Defaults to `false`. + * Takes precedence over chrome service visibility settings. + */ + chromeless?: boolean; } +/** + * Accessibility status of an application. + * + * @public + */ +export enum AppStatus { + /** + * Application is accessible. + */ + accessible = 0, + /** + * Application is not accessible. + */ + inaccessible = 1, +} + +/** + * Status of the application's navLink. + * + * @public + */ +export enum AppNavLinkStatus { + /** + * The application navLink will be `visible` if the application's {@link AppStatus} is set to `accessible` + * and `hidden` if the application status is set to `inaccessible`. + */ + default = 0, + /** + * The application navLink is visible and clickable in the navigation bar. + */ + visible = 1, + /** + * The application navLink is visible but inactive and not clickable in the navigation bar. + */ + disabled = 2, + /** + * The application navLink does not appear in the navigation bar. + */ + hidden = 3, +} + +/** + * Defines the list of fields that can be updated via an {@link AppUpdater}. + * @public + */ +export type AppUpdatableFields = Pick; + +/** + * Updater for applications. + * see {@link ApplicationSetup} + * @public + */ +export type AppUpdater = (app: AppBase) => Partial | undefined; + /** * Extension of {@link AppBase | common app properties} with the mount function. * @public @@ -374,6 +492,35 @@ export interface ApplicationSetup { */ register(app: App): void; + /** + * Register an application updater that can be used to change the {@link AppUpdatableFields} fields + * of all applications at runtime. + * + * This is meant to be used by plugins that needs to updates the whole list of applications. + * To only updates a specific application, use the `updater$` property of the registered application instead. + * + * @example + * + * How to register an application updater that disables some applications: + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * setup({ application }) { + * application.registerAppUpdater( + * new BehaviorSubject(app => { + * if (myPluginApi.shouldDisable(app)) + * return { + * status: AppStatus.inaccessible, + * }; + * }) + * ); + * } + * } + * ``` + */ + registerAppUpdater(appUpdater$: Observable): void; + /** * Register a context provider for application mounting. Will only be available to applications that depend on the * plugin that registered this context. Deprecated, use {@link CoreSetup.getStartServices}. @@ -389,7 +536,7 @@ export interface ApplicationSetup { } /** @internal */ -export interface InternalApplicationSetup { +export interface InternalApplicationSetup extends Pick { /** * Register an mountable application to the system. * @param plugin - opaque ID of the plugin that registers this application @@ -462,16 +609,11 @@ export interface ApplicationStart { export interface InternalApplicationStart extends Pick { /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - */ - availableApps: ReadonlyMap; - /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - * @internal + * Apps available based on the current capabilities. + * Should be used to show navigation links and make routing decisions. + * Applications manually disabled from the client-side using {@link AppUpdater} */ - availableLegacyApps: ReadonlyMap; + applications$: Observable>; /** * Register a context provider for application mounting. Will only be available to applications that depend on the diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index d9c35b20db03b..abd04722a49f2 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -18,7 +18,7 @@ */ import * as Rx from 'rxjs'; -import { toArray } from 'rxjs/operators'; +import { take, toArray } from 'rxjs/operators'; import { shallow } from 'enzyme'; import React from 'react'; @@ -54,7 +54,9 @@ function defaultStartDeps(availableApps?: App[]) { }; if (availableApps) { - deps.application.availableApps = new Map(availableApps.map(app => [app.id, app])); + deps.application.applications$ = new Rx.BehaviorSubject>( + new Map(availableApps.map(app => [app.id, app])) + ); } return deps; @@ -211,13 +213,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, navigateToApp } = startDeps.application; + const { applications$, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); + const availableApps = await applications$.pipe(take(1)).toPromise(); [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 18c0c9870d72f..09ea1afe35766 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { BehaviorSubject, Observable, ReplaySubject, combineLatest, of, merge } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { i18n } from '@kbn/i18n'; @@ -118,15 +118,16 @@ export class ChromeService { // combineLatest below regardless of having an application value yet. of(isEmbedded), application.currentAppId$.pipe( - map( - appId => - !!appId && - application.availableApps.has(appId) && - !!application.availableApps.get(appId)!.chromeless + flatMap(appId => + application.applications$.pipe( + map(applications => { + return !!appId && applications.has(appId) && !!applications.get(appId)!.chromeless; + }) + ) ) ) ); - this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe( + this.isVisible$ = combineLatest([this.appHidden$, this.toggleHidden$]).pipe( map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)), takeUntil(this.stop$) ); diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 5a45491df28e7..3d9a4bfdb6a56 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -20,34 +20,47 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App, LegacyApp } from '../../application'; +import { BehaviorSubject } from 'rxjs'; -const mockAppService = { - availableApps: new Map( - ([ - { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }, - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }, - ] as App[]).map(app => [app.id, app]) - ), - availableLegacyApps: new Map( - ([ - { id: 'legacyApp1', order: 5, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, - { - id: 'legacyApp2', - order: -5, - title: 'Legacy App 2', - euiIconType: 'canvasApp', - appUrl: '/app2', - }, - { id: 'legacyApp3', order: 15, title: 'Legacy App 3', appUrl: '/app3' }, - ] as LegacyApp[]).map(app => [app.id, app]) - ), -} as any; +const availableApps = new Map([ + ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], + ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], + [ + 'legacyApp1', + { + id: 'legacyApp1', + order: 5, + title: 'Legacy App 1', + icon: 'legacyApp1', + appUrl: '/app1', + legacy: true, + }, + ], + [ + 'legacyApp2', + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + legacy: true, + }, + ], + [ + 'legacyApp3', + { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3', legacy: true }, + ], +]); const mockHttp = { basePath: { @@ -57,10 +70,16 @@ const mockHttp = { describe('NavLinksService', () => { let service: NavLinksService; + let mockAppService: any; let start: ReturnType; beforeEach(() => { service = new NavLinksService(); + mockAppService = { + applications$: new BehaviorSubject>( + availableApps as any + ), + }; start = service.start({ application: mockAppService, http: mockHttp }); }); @@ -183,22 +202,36 @@ describe('NavLinksService', () => { .toPromise() ).toEqual(['legacyApp1']); }); + + it('still removes all other links when availableApps are re-emitted', async () => { + start.showOnly('legacyApp2'); + mockAppService.applications$.next(mockAppService.applications$.value); + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.map(l => l.id)) + ) + .toPromise() + ).toEqual(['legacyApp2']); + }); }); describe('#update()', () => { it('updates the navlinks and returns the updated link', async () => { - expect(start.update('legacyApp1', { hidden: true })).toMatchInlineSnapshot(` - Object { - "appUrl": "/app1", - "baseUrl": "http://localhost/wow/app1", - "hidden": true, - "icon": "legacyApp1", - "id": "legacyApp1", - "legacy": true, - "order": 5, - "title": "Legacy App 1", - } - `); + expect(start.update('legacyApp1', { hidden: true })).toEqual( + expect.objectContaining({ + appUrl: '/app1', + disabled: false, + hidden: true, + icon: 'legacyApp1', + id: 'legacyApp1', + legacy: true, + order: 5, + title: 'Legacy App 1', + }) + ); const hiddenLinkIds = await start .getNavLinks$() .pipe( @@ -212,6 +245,19 @@ describe('NavLinksService', () => { it('returns undefined if link does not exist', () => { expect(start.update('fake', { hidden: true })).toBeUndefined(); }); + + it('keeps the updated link when availableApps are re-emitted', async () => { + start.update('legacyApp1', { hidden: true }); + mockAppService.applications$.next(mockAppService.applications$.value); + const hiddenLinkIds = await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.filter(l => l.hidden).map(l => l.id)) + ) + .toPromise(); + expect(hiddenLinkIds).toEqual(['legacyApp1']); + }); }); describe('#enableForcedAppSwitcherNavigation()', () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 31a729f90cd93..650ef77b6fe42 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -18,11 +18,13 @@ */ import { sortBy } from 'lodash'; -import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { NavLinkWrapper, ChromeNavLinkUpdateableFields, ChromeNavLink } from './nav_link'; + import { InternalApplicationStart } from '../../application'; import { HttpStart } from '../../http'; +import { ChromeNavLink, ChromeNavLinkUpdateableFields, NavLinkWrapper } from './nav_link'; +import { toNavLink } from './to_nav_link'; interface StartDeps { application: InternalApplicationStart; @@ -95,39 +97,38 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } +type LinksUpdater = (navLinks: Map) => Map; + export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { - const appLinks = [...application.availableApps] - .filter(([, app]) => !app.chromeless) - .map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: false, - baseUrl: relativeToAbsolute(http.basePath.prepend(`/app/${appId}`)), - }), - ] as [string, NavLinkWrapper] - ); - - const legacyAppLinks = [...application.availableLegacyApps].map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: true, - baseUrl: relativeToAbsolute(http.basePath.prepend(app.appUrl)), - }), - ] as [string, NavLinkWrapper] + const appLinks$ = application.applications$.pipe( + map(apps => { + return new Map( + [...apps] + .filter(([, app]) => !app.chromeless) + .map(([appId, app]) => [appId, toNavLink(app, http.basePath)]) + ); + }) ); - const navLinks$ = new BehaviorSubject>( - new Map([...legacyAppLinks, ...appLinks]) - ); + // now that availableApps$ is an observable, we need to keep record of all + // manual link modifications to be able to re-apply then after every + // availableApps$ changes. + const linkUpdaters$ = new BehaviorSubject([]); + const navLinks$ = new BehaviorSubject>(new Map()); + + combineLatest([appLinks$, linkUpdaters$]) + .pipe( + map(([appLinks, linkUpdaters]) => { + return linkUpdaters.reduce((links, updater) => updater(links), appLinks); + }) + ) + .subscribe(navlinks => { + navLinks$.next(navlinks); + }); + const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { @@ -153,7 +154,10 @@ export class NavLinksService { return; } - navLinks$.next(new Map([...navLinks$.value.entries()].filter(([linkId]) => linkId === id))); + const updater: LinksUpdater = navLinks => + new Map([...navLinks.entries()].filter(([linkId]) => linkId === id)); + + linkUpdaters$.next([...linkUpdaters$.value, updater]); }, update(id: string, values: ChromeNavLinkUpdateableFields) { @@ -161,17 +165,17 @@ export class NavLinksService { return; } - navLinks$.next( + const updater: LinksUpdater = navLinks => new Map( - [...navLinks$.value.entries()].map(([linkId, link]) => { + [...navLinks.entries()].map(([linkId, link]) => { return [linkId, link.id === id ? link.update(values) : link] as [ string, NavLinkWrapper ]; }) - ) - ); + ); + linkUpdaters$.next([...linkUpdaters$.value, updater]); return this.get(id); }, @@ -196,10 +200,3 @@ function sortNavLinks(navLinks: ReadonlyMap) { 'order' ); } - -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts new file mode 100644 index 0000000000000..23fdabe0f3430 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppMount, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { toNavLink } from './to_nav_link'; + +import { httpServiceMock } from '../../mocks'; + +function mount() {} + +const app = (props: Partial = {}): App => ({ + mount: (mount as unknown) as AppMount, + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + appRoute: `/app/some-id`, + legacy: false, + ...props, +}); + +const legacyApp = (props: Partial = {}): LegacyApp => ({ + appUrl: '/my-app-url', + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + legacy: true, + ...props, +}); + +describe('toNavLink', () => { + const basePath = httpServiceMock.createSetupContract({ basePath: '/base-path' }).basePath; + + it('uses the application properties when creating the navLink', () => { + const link = toNavLink( + app({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }), + basePath + ); + expect(link.properties).toEqual( + expect.objectContaining({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }) + ); + }); + + it('flags legacy apps when converting to navLink', () => { + expect(toNavLink(app({}), basePath).properties.legacy).toEqual(false); + expect(toNavLink(legacyApp({}), basePath).properties.legacy).toEqual(true); + }); + + it('handles applications with custom app route', () => { + const link = toNavLink( + app({ + appRoute: '/my-route/my-path', + }), + basePath + ); + expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path'); + }); + + it('uses appUrl when converting legacy applications', () => { + expect( + toNavLink( + legacyApp({ + appUrl: '/my-legacy-app/#foo', + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + baseUrl: 'http://localhost/base-path/my-legacy-app/#foo', + }) + ); + }); + + it('uses the application status when the navLinkStatus is set to default', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + }); + + it('uses the navLinkStatus of the application to set the hidden and disabled properties', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.visible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.hidden, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.disabled, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: true, + hidden: false, + }) + ); + }); +}); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts new file mode 100644 index 0000000000000..18e4b7b26b6ba --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { IBasePath } from '../../http'; +import { NavLinkWrapper } from './nav_link'; + +export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { + const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; + return new NavLinkWrapper({ + ...app, + hidden: useAppStatus + ? app.status === AppStatus.inaccessible + : app.navLinkStatus === AppNavLinkStatus.hidden, + disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, + legacy: isLegacyApp(app), + baseUrl: isLegacyApp(app) + ? relativeToAbsolute(basePath.prepend(app.appUrl)) + : relativeToAbsolute(basePath.prepend(app.appRoute!)), + }); +} + +function relativeToAbsolute(url: string) { + // convert all link urls to absolute urls + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} + +function isLegacyApp(app: App | LegacyApp): app is LegacyApp { + return app.legacy === true; +} diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 75f78ac8b2fa0..d05a6bb53405c 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -57,7 +57,7 @@ import { } from '../..'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; -import { ApplicationStart, InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application/types'; // Providing a buffer between the limit and the cut off index // protects from truncating just the last couple (6) characters @@ -108,7 +108,7 @@ function extendRecentlyAccessedHistoryItem( }; } -function extendNavLink(navLink: ChromeNavLink, urlForApp: ApplicationStart['getUrlForApp']) { +function extendNavLink(navLink: ChromeNavLink) { if (navLink.legacy) { return { ...navLink, @@ -118,7 +118,7 @@ function extendNavLink(navLink: ChromeNavLink, urlForApp: ApplicationStart['getU return { ...navLink, - href: urlForApp(navLink.id), + href: navLink.baseUrl, }; } @@ -229,9 +229,7 @@ class HeaderUI extends Component { appTitle, isVisible, forceNavigation, - navLinks: navLinks.map(navLink => - extendNavLink(navLink, this.props.application.getUrlForApp) - ), + navLinks: navLinks.map(extendNavLink), recentlyAccessed: recentlyAccessed.map(ra => extendRecentlyAccessedHistoryItem(navLinks, ra, this.props.basePath) ), @@ -309,7 +307,7 @@ class HeaderUI extends Component { .filter(navLink => !navLink.hidden) .map(navLink => ({ key: navLink.id, - label: navLink.title, + label: navLink.tooltip ?? navLink.title, // Use href and onClick to support "open in new tab" and SPA navigation in the same link href: navLink.href, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 44dc76bfe6e32..36b220f16f395 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -115,6 +115,9 @@ export class DocLinksService { date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, }, + management: { + kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, + }, }, }); } diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ea704749c6131..5b17eccc37f8b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,6 +94,10 @@ export { AppLeaveAction, AppLeaveDefaultAction, AppLeaveConfirmAction, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, } from './application'; export { diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index a4fdd86de5311..f906aff1759e2 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -81,6 +81,7 @@ export class LegacyPlatformService { ...core, getStartServices: () => this.startDependencies, application: { + ...core.application, register: notSupported(`core.application.register()`), registerMountContext: notSupported(`core.application.registerMountContext()`), }, diff --git a/src/core/public/notifications/toasts/global_toast_list.test.tsx b/src/core/public/notifications/toasts/global_toast_list.test.tsx index 61d73ac233188..dc2a9dabe791e 100644 --- a/src/core/public/notifications/toasts/global_toast_list.test.tsx +++ b/src/core/public/notifications/toasts/global_toast_list.test.tsx @@ -57,9 +57,9 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { it('passes latest value from toasts$ to ', () => { const el = shallow( render({ - toasts$: Rx.from([[], [{ id: 1 }], [{ id: 1 }, { id: 2 }]]) as any, + toasts$: Rx.from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, }) ); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: 1 }, { id: 2 }]); + expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); }); diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 848f46605d4de..f146c2452868b 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -96,6 +96,7 @@ export function createPluginSetupContext< return { application: { register: app => deps.application.register(plugin.opaqueId, app), + registerAppUpdater: statusUpdater$ => deps.application.registerAppUpdater(statusUpdater$), registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c76d6191de8a3..aef689162f45a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -26,13 +26,18 @@ export interface App extends AppBase { // @public (undocumented) export interface AppBase { capabilities?: Partial; + chromeless?: boolean; euiIconType?: string; icon?: string; - // (undocumented) id: string; + // @internal + legacy?: boolean; + navLinkStatus?: AppNavLinkStatus; order?: number; + status?: AppStatus; title: string; - tooltip$?: Observable; + tooltip?: string; + updater$?: Observable; } // @public @@ -74,6 +79,7 @@ export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction // @public (undocumented) export interface ApplicationSetup { register(app: App): void; + registerAppUpdater(appUpdater$: Observable): void; // @deprecated registerMountContext(contextName: T, provider: IContextProvider): void; } @@ -123,9 +129,29 @@ export interface AppMountParameters { onAppLeave: (handler: AppLeaveHandler) => void; } +// @public +export enum AppNavLinkStatus { + default = 0, + disabled = 2, + hidden = 3, + visible = 1 +} + +// @public +export enum AppStatus { + accessible = 0, + inaccessible = 1 +} + // @public export type AppUnmount = () => void; +// @public +export type AppUpdatableFields = Pick; + +// @public +export type AppUpdater = (app: AppBase) => Partial | undefined; + // @public export interface Capabilities { [key: string]: Record>; diff --git a/src/core/public/rendering/app_containers.test.tsx b/src/core/public/rendering/app_containers.test.tsx new file mode 100644 index 0000000000000..746e37b1214d9 --- /dev/null +++ b/src/core/public/rendering/app_containers.test.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import React from 'react'; + +import { AppWrapper, AppContainer } from './app_containers'; + +describe('AppWrapper', () => { + it('toggles the `hidden-chrome` class depending on the chrome visibility state', () => { + const chromeVisible$ = new BehaviorSubject(true); + + const component = mount(app-content); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => chromeVisible$.next(false)); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => chromeVisible$.next(true)); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + }); +}); + +describe('AppContainer', () => { + it('adds classes supplied by chrome', () => { + 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 new file mode 100644 index 0000000000000..72faaeac588be --- /dev/null +++ b/src/core/public/rendering/app_containers.tsx @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { Observable } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; +import classNames from 'classnames'; + +export const AppWrapper: React.FunctionComponent<{ + chromeVisible$: Observable; +}> = ({ chromeVisible$, children }) => { + const visible = useObservable(chromeVisible$); + return
{children}
; +}; + +export const AppContainer: React.FunctionComponent<{ + classes$: Observable; +}> = ({ classes$, children }) => { + 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 ed835574a32f9..437a602a3d447 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -18,72 +18,129 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; -import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { RenderingService } from './rendering_service'; -import { InternalApplicationStart } from '../application'; +import { applicationServiceMock } from '../application/application_service.mock'; +import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; +import { BehaviorSubject } from 'rxjs'; describe('RenderingService#start', () => { - const getService = ({ legacyMode = false }: { legacyMode?: boolean } = {}) => { - const rendering = new RenderingService(); - const application = { - getComponent: () =>
Hello application!
, - } as InternalApplicationStart; - const chrome = chromeServiceMock.createStartContract(); + let application: ReturnType; + let chrome: ReturnType; + let overlays: ReturnType; + let injectedMetadata: ReturnType; + let targetDomElement: HTMLDivElement; + let rendering: RenderingService; + + beforeEach(() => { + application = applicationServiceMock.createInternalStartContract(); + application.getComponent.mockReturnValue(
Hello application!
); + + chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); - const overlays = overlayServiceMock.createStartContract(); + + overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(legacyMode); - const targetDomElement = document.createElement('div'); - const start = rendering.start({ + injectedMetadata = injectedMetadataServiceMock.createStartContract(); + + targetDomElement = document.createElement('div'); + + rendering = new RenderingService(); + }); + + const startService = () => { + return rendering.start({ application, chrome, injectedMetadata, overlays, targetDomElement, }); - return { start, targetDomElement }; }; - it('renders application service into provided DOM element', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` -
-
- Hello application! -
-
- `); - }); + describe('standard mode', () => { + beforeEach(() => { + injectedMetadata.getLegacyMode.mockReturnValue(false); + }); - it('contains wrapper divs', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); - expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); - }); + it('renders application service into provided DOM element', () => { + startService(); + expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` +
+
+ Hello application! +
+
+ `); + }); + + it('adds the `chrome-hidden` class to the AppWrapper when chrome is hidden', () => { + const isVisible$ = new BehaviorSubject(true); + chrome.getIsVisible$.mockReturnValue(isVisible$); + startService(); + + const appWrapper = targetDomElement.querySelector('div.app-wrapper')!; + expect(appWrapper.className).toEqual('app-wrapper'); + + act(() => isVisible$.next(false)); + expect(appWrapper.className).toEqual('app-wrapper hidden-chrome'); - it('renders the banner UI', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` -
-
- I'm a banner! -
-
- `); + act(() => isVisible$.next(true)); + expect(appWrapper.className).toEqual('app-wrapper'); + }); + + it('adds the application classes to the AppContainer', () => { + const applicationClasses$ = new BehaviorSubject([]); + chrome.getApplicationClasses$.mockReturnValue(applicationClasses$); + startService(); + + const appContainer = targetDomElement.querySelector('div.application')!; + expect(appContainer.className).toEqual('application'); + + act(() => applicationClasses$.next(['classA', 'classB'])); + expect(appContainer.className).toEqual('application classA classB'); + + act(() => applicationClasses$.next(['classC'])); + expect(appContainer.className).toEqual('application classC'); + + act(() => applicationClasses$.next([])); + expect(appContainer.className).toEqual('application'); + }); + + it('contains wrapper divs', () => { + startService(); + expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); + expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); + }); + + it('renders the banner UI', () => { + startService(); + expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` +
+
+ I'm a banner! +
+
+ `); + }); }); - describe('legacyMode', () => { + describe('legacy mode', () => { + beforeEach(() => { + injectedMetadata.getLegacyMode.mockReturnValue(true); + }); + it('renders into provided DOM element', () => { - const { targetDomElement } = getService({ legacyMode: true }); + startService(); + expect(targetDomElement).toMatchInlineSnapshot(`
{ }); it('returns a div for the legacy service to render into', () => { - const { - start: { legacyTargetDomElement }, - targetDomElement, - } = getService({ legacyMode: true }); + const { legacyTargetDomElement } = startService(); + expect(targetDomElement.contains(legacyTargetDomElement!)).toBe(true); }); }); diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 7a747faa2673f..58b8c1921e333 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -25,6 +25,7 @@ import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; import { InjectedMetadataStart } from '../injected_metadata'; import { OverlayStart } from '../overlays'; +import { AppWrapper, AppContainer } from './app_containers'; interface StartDeps { application: InternalApplicationStart; @@ -65,12 +66,12 @@ export class RenderingService { {chromeUi} {!legacyMode && ( -
+
{bannerUi}
-
{appUi}
+ {appUi}
-
+ )} {legacyMode &&
} diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 1b52f22c4da09..a4e51ca55b3e7 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -74,8 +74,6 @@ const createInternalSetupContractMock = () => { legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, - adminClient$: new BehaviorSubject(createClusterClientMock()), - dataClient$: new BehaviorSubject(createClusterClientMock()), }; setupContract.adminClient.asScoped.mockReturnValue(createScopedClusterClientMock()); setupContract.dataClient.asScoped.mockReturnValue(createScopedClusterClientMock()); diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 9f694ac1c46da..5a7d223fec7ad 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -21,7 +21,7 @@ import { first } from 'rxjs/operators'; import { MockClusterClient } from './elasticsearch_service.test.mocks'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; @@ -91,44 +91,6 @@ describe('#setup', () => { expect(mockDataClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); }); - it('returns data and admin client observables as a part of the contract', async () => { - const mockAdminClusterClientInstance = { close: jest.fn() }; - const mockDataClusterClientInstance = { close: jest.fn() }; - MockClusterClient.mockImplementationOnce( - () => mockAdminClusterClientInstance - ).mockImplementationOnce(() => mockDataClusterClientInstance); - - const setupContract = await elasticsearchService.setup(deps); - - const [esConfig, adminClient, dataClient] = await combineLatest( - setupContract.legacy.config$, - setupContract.adminClient$, - setupContract.dataClient$ - ) - .pipe(first()) - .toPromise(); - - expect(adminClient).toBe(mockAdminClusterClientInstance); - expect(dataClient).toBe(mockDataClusterClientInstance); - - expect(MockClusterClient).toHaveBeenCalledTimes(2); - expect(MockClusterClient).toHaveBeenNthCalledWith( - 1, - esConfig, - expect.objectContaining({ context: ['elasticsearch', 'admin'] }), - undefined - ); - expect(MockClusterClient).toHaveBeenNthCalledWith( - 2, - esConfig, - expect.objectContaining({ context: ['elasticsearch', 'data'] }), - expect.any(Function) - ); - - expect(mockAdminClusterClientInstance.close).not.toHaveBeenCalled(); - expect(mockDataClusterClientInstance.close).not.toHaveBeenCalled(); - }); - describe('#createClient', () => { it('allows to specify config properties', async () => { const setupContract = await elasticsearchService.setup(deps); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index db3fda3a504ab..aba246ce66fb5 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -152,8 +152,6 @@ export class ElasticsearchService implements CoreService clients.config)) }, - adminClient$, - dataClient$, adminClient, dataClient, diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 22340bf3f2fc6..899b273c5c60a 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -77,7 +77,4 @@ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceS readonly legacy: { readonly config$: Observable; }; - - readonly adminClient$: Observable; - readonly dataClient$: Observable; } diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index 3982df567ed7c..6fa3357168027 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -16,8 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; export const clusterClientMock = jest.fn(); jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({ - ScopedClusterClient: clusterClientMock, + ScopedClusterClient: clusterClientMock.mockImplementation(function() { + return elasticsearchServiceMock.createScopedClusterClient(); + }), })); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index f3867faa2ae75..65c4f1432721d 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -133,7 +133,7 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth } = http; - await registerAuth((req, res, toolkit) => { + registerAuth((req, res, toolkit) => { return toolkit.authenticated({ responseHeaders: authResponseHeader }); }); @@ -157,7 +157,7 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth } = http; - await registerAuth((req, res, toolkit) => { + registerAuth((req, res, toolkit) => { return toolkit.authenticated({ responseHeaders: authResponseHeader }); }); @@ -222,12 +222,15 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth, createRouter } = http; - await registerAuth((req, res, toolkit) => - toolkit.authenticated({ requestHeaders: authHeaders }) - ); + registerAuth((req, res, toolkit) => toolkit.authenticated({ requestHeaders: authHeaders })); const router = createRouter('/new-platform'); - router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + router.get({ path: '/', validate: false }, async (context, req, res) => { + // it forces client initialization since the core creates them lazily. + await context.core.elasticsearch.adminClient.callAsCurrentUser('ping'); + await context.core.elasticsearch.dataClient.callAsCurrentUser('ping'); + return res.ok(); + }); await root.start(); @@ -247,7 +250,12 @@ describe('http service', () => { const { createRouter } = http; const router = createRouter('/new-platform'); - router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + router.get({ path: '/', validate: false }, async (context, req, res) => { + // it forces client initialization since the core creates them lazily. + await context.core.elasticsearch.adminClient.callAsCurrentUser('ping'); + await context.core.elasticsearch.dataClient.callAsCurrentUser('ping'); + return res.ok(); + }); await root.start(); diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 608392e4943f9..af4db68ee95e1 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -424,7 +424,7 @@ describe('#discoverPlugins()', () => { await legacyService.discoverPlugins(); expect(findLegacyPluginSpecs).toHaveBeenCalledTimes(1); - expect(findLegacyPluginSpecs).toHaveBeenCalledWith(expect.any(Object), logger); + expect(findLegacyPluginSpecs).toHaveBeenCalledWith(expect.any(Object), logger, env.packageInfo); }); it(`register legacy plugin's deprecation providers`, async () => { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index cc36b90ec526d..7a03cefc38c1a 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -125,7 +125,11 @@ export class LegacyService implements CoreService { disabledPluginSpecs, uiExports, navLinks, - } = await findLegacyPluginSpecs(this.settings, this.coreContext.logger); + } = await findLegacyPluginSpecs( + this.settings, + this.coreContext.logger, + this.coreContext.env.packageInfo + ); this.legacyPlugins = { pluginSpecs, diff --git a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts index d2e7a39236d0a..9867274d224bd 100644 --- a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts +++ b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts @@ -29,6 +29,8 @@ import { import { collectUiExports as collectLegacyUiExports } from '../../../../legacy/ui/ui_exports/collect_ui_exports'; import { LoggerFactory } from '../../logging'; +import { PackageInfo } from '../../config'; + import { LegacyUiExports, LegacyNavLink, @@ -92,7 +94,11 @@ function getNavLinks(uiExports: LegacyUiExports, pluginSpecs: LegacyPluginSpec[] .sort((a, b) => a.order - b.order); } -export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: LoggerFactory) { +export async function findLegacyPluginSpecs( + settings: unknown, + loggerFactory: LoggerFactory, + packageInfo: PackageInfo +) { const configToMutate: LegacyConfig = defaultConfig(settings); const { pack$, @@ -152,8 +158,7 @@ export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: Lo map(spec => { const name = spec.getId(); const pluginVersion = spec.getExpectedKibanaVersion(); - // @ts-ignore - const kibanaVersion = settings.pkg.version; + const kibanaVersion = packageInfo.version; return `Plugin "${name}" was disabled because it expected Kibana version "${pluginVersion}", and found "${kibanaVersion}".`; }), distinct(), diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 073d380d3aa67..c7082d46313ae 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -37,6 +37,7 @@ export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service. export { httpServiceMock } from './http/http_service.mock'; export { loggingServiceMock } from './logging/logging_service.mock'; export { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; +export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { uuidServiceMock } from './uuid/uuid_service.mock'; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index bf7dc14c73265..7f3a960571012 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1928,6 +1928,8 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ // @public export interface UiSettingsParams { category?: string[]; + // Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts + deprecation?: DeprecationSettings; description?: string; name?: string; optionLabels?: Record; @@ -1935,6 +1937,11 @@ export interface UiSettingsParams { readonly?: boolean; requiresPageReload?: boolean; type?: UiSettingsType; + // Warning: (ae-forgotten-export) The symbol "ImageValidation" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "StringValidation" needs to be exported by the entry point index.d.ts + // + // (undocumented) + validation?: ImageValidation | StringValidation; value?: SavedObjectAttribute; } diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 611842e8a7de0..7c3f9f249db13 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -17,7 +17,6 @@ * under the License. */ -import { take } from 'rxjs/operators'; import { Type } from '@kbn/config-schema'; import { @@ -216,9 +215,6 @@ export class Server { coreId, 'core', async (context, req, res): Promise => { - // it consumes elasticsearch observables to provide the same client throughout the context lifetime. - const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise(); - const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise(); const savedObjectsClient = coreSetup.savedObjects.getScopedClient(req); const uiSettingsClient = coreSetup.uiSettings.asScopedToClient(savedObjectsClient); @@ -230,8 +226,8 @@ export class Server { client: savedObjectsClient, }, elasticsearch: { - adminClient: adminClient.asScoped(req), - dataClient: dataClient.asScoped(req), + adminClient: coreSetup.elasticsearch.adminClient.asScoped(req), + dataClient: coreSetup.elasticsearch.dataClient.asScoped(req), }, uiSettings: { client: uiSettingsClient, diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 9919c7f0386b5..2433aad1a2be5 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -23,4 +23,3 @@ export * from './saved_objects/types'; export * from './ui_settings/types'; export * from './legacy/types'; export { EnvironmentMode, PackageInfo } from './config/types'; -export { ICspConfig } from './csp'; diff --git a/src/core/server/ui_settings/types.ts b/src/core/server/ui_settings/types.ts index 5e3f0a4fbb6bd..14eb71a22cefc 100644 --- a/src/core/server/ui_settings/types.ts +++ b/src/core/server/ui_settings/types.ts @@ -73,6 +73,15 @@ export interface UserProvidedValues { isOverridden?: boolean; } +/** + * UiSettings deprecation field options. + * @public + * */ +export interface DeprecationSettings { + message: string; + docLinksKey: string; +} + /** * UI element type to represent the settings. * @public @@ -102,6 +111,25 @@ export interface UiSettingsParams { readonly?: boolean; /** defines a type of UI element {@link UiSettingsType} */ type?: UiSettingsType; + /** optional deprecation information. Used to generate a deprecation warning. */ + deprecation?: DeprecationSettings; + /* + * Allows defining a custom validation applicable to value change on the client. + * @deprecated + */ + validation?: ImageValidation | StringValidation; +} + +export interface StringValidation { + regexString: string; + message: string; +} + +export interface ImageValidation { + maxSize: { + length: number; + description: string; + }; } /** @internal */ diff --git a/src/core/utils/merge.test.ts b/src/core/utils/merge.test.ts index aa98f51067411..c857e980dec21 100644 --- a/src/core/utils/merge.test.ts +++ b/src/core/utils/merge.test.ts @@ -61,4 +61,15 @@ describe('merge', () => { expect(merge({ a: 0 }, {}, {})).toEqual({ a: 0 }); expect(merge({ a: 0 }, { a: 1 }, {})).toEqual({ a: 1 }); }); + + test(`doesn't pollute prototypes`, () => { + merge({}, JSON.parse('{ "__proto__": { "foo": "bar" } }')); + merge({}, JSON.parse('{ "constructor": { "prototype": { "foo": "bar" } } }')); + merge( + {}, + JSON.parse('{ "__proto__": { "foo": "bar" } }'), + JSON.parse('{ "constructor": { "prototype": { "foo": "bar" } } }') + ); + expect(({} as any).foo).toBe(undefined); + }); }); diff --git a/src/dev/build/README.md b/src/dev/build/README.md index af08414f0bf4b..3b579033fabe1 100644 --- a/src/dev/build/README.md +++ b/src/dev/build/README.md @@ -44,7 +44,7 @@ The majority of this logic is extracted from the grunt build that has existed fo We have introduced in our bundle a webpack dll for the client vendor modules in order to improve the optimization time both in dev and in production. As for those modules we already have the -code into the vendors.bundle.dll.js we have decided to delete those bundled modules from the +code into the vendors_${chunk_number}.bundle.dll.js we have decided to delete those bundled modules from the distributable node_modules folder. However, in order to accomplish this, we need to exclude every node_module used in the server side code. This logic is performed under `nodejs_modules/clean_client_modules_on_dll_task.js`. In case we need to add any new cli diff --git a/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js b/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js index 19d74bcf89e30..52928d6e47fc4 100644 --- a/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js +++ b/src/dev/build/tasks/nodejs_modules/clean_client_modules_on_dll_task.js @@ -98,12 +98,16 @@ export const CleanClientModulesOnDLLTask = { // Consider this as our whiteList for the modules we can't delete const whiteListedModules = [...serverDependencies, ...kbnWebpackLoaders, ...manualExceptions]; - // Resolve the client vendors dll manifest path - const dllManifestPath = `${baseDir}/built_assets/dlls/vendors.manifest.dll.json`; + // Resolve the client vendors dll manifest paths + // excluding the runtime one + const dllManifestPaths = await globby([ + `${baseDir}/built_assets/dlls/vendors_*.manifest.dll.json`, + `!${baseDir}/built_assets/dlls/vendors_runtime.manifest.dll.json`, + ]); // Get dll entries filtering out the ones // from any whitelisted module - const dllEntries = await getDllEntries(dllManifestPath, whiteListedModules, baseDir); + const dllEntries = await getDllEntries(dllManifestPaths, whiteListedModules, baseDir); for (const relativeEntryPath of dllEntries) { const entryPath = `${baseDir}/${relativeEntryPath}`; diff --git a/src/dev/build/tasks/nodejs_modules/webpack_dll.js b/src/dev/build/tasks/nodejs_modules/webpack_dll.js index ea8cc1e286407..72910226bb04a 100644 --- a/src/dev/build/tasks/nodejs_modules/webpack_dll.js +++ b/src/dev/build/tasks/nodejs_modules/webpack_dll.js @@ -28,27 +28,37 @@ function checkDllEntryAccess(entry, baseDir = '') { return isFileAccessible(resolvedPath); } -export async function getDllEntries(manifestPath, whiteListedModules, baseDir = '') { - const manifest = JSON.parse(await read(manifestPath)); - - if (!manifest || !manifest.content) { - // It should fails because if we don't have the manifest file - // or it is malformed something wrong is happening and we - // should stop - throw new Error(`The following dll manifest doesn't exists: ${manifestPath}`); - } +export async function getDllEntries(manifestPaths, whiteListedModules, baseDir = '') { + // Read and parse all manifests + const manifests = await Promise.all( + manifestPaths.map(async manifestPath => JSON.parse(await read(manifestPath))) + ); - const modules = Object.keys(manifest.content); - if (!modules.length) { - // It should fails because if we don't have any - // module inside the client vendors dll something - // wrong is happening and we should stop too - throw new Error(`The following dll manifest is reporting an empty dll: ${manifestPath}`); - } + // Process and group modules from all manifests + const manifestsModules = manifests.flatMap((manifest, idx) => { + if (!manifest || !manifest.content) { + // It should fails because if we don't have the manifest file + // or it is malformed something wrong is happening and we + // should stop + throw new Error(`The following dll manifest doesn't exists: ${manifestPaths[idx]}`); + } + + const modules = Object.keys(manifest.content); + if (!modules.length) { + // It should fails because if we don't have any + // module inside the client vendors dll something + // wrong is happening and we should stop too + throw new Error( + `The following dll manifest is reporting an empty dll: ${manifestPaths[idx]}` + ); + } + + return modules; + }); // Only includes modules who are not in the white list of modules // and that are node_modules - return modules.filter(entry => { + return manifestsModules.filter(entry => { const isWhiteListed = whiteListedModules.some(nonEntry => normalizePosixPath(entry).includes(`node_modules/${nonEntry}`) ); diff --git a/src/dev/build/tasks/nodejs_modules/webpack_dll.test.js b/src/dev/build/tasks/nodejs_modules/webpack_dll.test.js index 1fdd7d8d4f5ff..ce305169a777b 100644 --- a/src/dev/build/tasks/nodejs_modules/webpack_dll.test.js +++ b/src/dev/build/tasks/nodejs_modules/webpack_dll.test.js @@ -52,7 +52,7 @@ describe('Webpack DLL Build Tasks Utils', () => { isFileAccessible.mockImplementation(() => true); - const mockManifestPath = '/mock/mock_dll_manifest.json'; + const mockManifestPath = ['/mock/mock_dll_manifest.json']; const mockModulesWhitelist = ['dep1']; const dllEntries = await getDllEntries(mockManifestPath, mockModulesWhitelist); @@ -66,7 +66,7 @@ describe('Webpack DLL Build Tasks Utils', () => { isFileAccessible.mockImplementation(() => false); - const mockManifestPath = '/mock/mock_dll_manifest.json'; + const mockManifestPath = ['/mock/mock_dll_manifest.json']; const mockModulesWhitelist = ['dep1']; const dllEntries = await getDllEntries(mockManifestPath, mockModulesWhitelist); @@ -78,7 +78,7 @@ describe('Webpack DLL Build Tasks Utils', () => { it('should throw an error for no manifest file', async () => { read.mockImplementationOnce(async () => noManifestMock); - const mockManifestPath = '/mock/mock_dll_manifest.json'; + const mockManifestPath = ['/mock/mock_dll_manifest.json']; try { await getDllEntries(mockManifestPath, []); @@ -92,7 +92,7 @@ describe('Webpack DLL Build Tasks Utils', () => { it('should throw an error for no manifest content field', async () => { read.mockImplementation(async () => noContentFieldManifestMock); - const mockManifestPath = '/mock/mock_dll_manifest.json'; + const mockManifestPath = ['/mock/mock_dll_manifest.json']; try { await getDllEntries(mockManifestPath, []); @@ -106,7 +106,7 @@ describe('Webpack DLL Build Tasks Utils', () => { it('should throw an error for manifest file without any content', async () => { read.mockImplementation(async () => emptyManifestContentMock); - const mockManifestPath = '/mock/mock_dll_manifest.json'; + const mockManifestPath = ['/mock/mock_dll_manifest.json']; try { await getDllEntries(mockManifestPath, []); diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index a4aa3474c0762..bd084767a723f 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -23,6 +23,7 @@ export const LICENSE_WHITELIST = [ 'Elastic-License', '(BSD-2-Clause OR MIT OR Apache-2.0)', '(BSD-2-Clause OR MIT)', + '(BSD-3-Clause AND Apache-2.0)', '(GPL-2.0 OR MIT)', '(MIT AND CC-BY-3.0)', '(MIT AND Zlib)', diff --git a/src/dev/sass/build_sass.js b/src/dev/sass/build_sass.js index 14f03a7a116a6..1ff7c700d0386 100644 --- a/src/dev/sass/build_sass.js +++ b/src/dev/sass/build_sass.js @@ -19,6 +19,7 @@ import { resolve } from 'path'; +import * as Rx from 'rxjs'; import { toArray } from 'rxjs/operators'; import { createFailError } from '@kbn/dev-utils'; @@ -61,9 +62,11 @@ export async function buildSass({ log, kibanaDir, watch }) { const scanDirs = [resolve(kibanaDir, 'src/legacy/core_plugins')]; const paths = [resolve(kibanaDir, 'x-pack')]; - const { spec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); - const enabledPlugins = await spec$.pipe(toArray()).toPromise(); - const uiExports = collectUiExports(enabledPlugins); + const { spec$, disabledSpec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); + const allPlugins = await Rx.merge(spec$, disabledSpec$) + .pipe(toArray()) + .toPromise(); + const uiExports = collectUiExports(allPlugins); const { styleSheetPaths } = uiExports; log.info('%s %d styleSheetPaths', watch ? 'watching' : 'found', styleSheetPaths.length); diff --git a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx index 40b9cc4640eef..761a252b56a87 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx @@ -164,7 +164,7 @@ function EditorUI() { mappings.retrieveAutoCompleteInfo(); - const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor.getCoreEditor()); + const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { diff --git a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts index 4ecd5d415833c..1adc56d47927b 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts @@ -22,8 +22,15 @@ export function subscribeResizeChecker(el: HTMLElement, ...editors: any[]) { const checker = new ResizeChecker(el); checker.on('resize', () => editors.forEach(e => { - e.resize(); - if (e.updateActionsBar) e.updateActionsBar(); + if (e.getCoreEditor) { + e.getCoreEditor().resize(); + } else { + e.resize(); + } + + if (e.updateActionsBar) { + e.updateActionsBar(); + } }) ); return () => checker.destroy(); diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts index 608c73335b3e5..8301daa675b5c 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts @@ -297,30 +297,32 @@ export class LegacyCoreEditor implements CoreEditor { // pageY is relative to page, so subtract the offset // from pageY to get the new top value const offsetFromPage = $(this.editor.container).offset()!.top; - const startRow = range.start.lineNumber - 1; + const startLine = range.start.lineNumber; const startColumn = range.start.column; - const firstLine = this.getLineValue(startRow); + const firstLine = this.getLineValue(startLine); const maxLineLength = this.getWrapLimit() - 5; const isWrapping = firstLine.length > maxLineLength; - const getScreenCoords = (row: number) => - this.editor.renderer.textToScreenCoordinates(row, startColumn).pageY - offsetFromPage; - const topOfReq = getScreenCoords(startRow); + const getScreenCoords = (line: number) => + this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - + offsetFromPage + + (window.pageYOffset || 0); + const topOfReq = getScreenCoords(startLine); if (topOfReq >= 0) { let offset = 0; if (isWrapping) { // Try get the line height of the text area in pixels. const textArea = $(this.editor.container.querySelector('textArea')!); - const hasRoomOnNextLine = this.getLineValue(startRow + 1).length < maxLineLength; + const hasRoomOnNextLine = this.getLineValue(startLine).length < maxLineLength; if (textArea && hasRoomOnNextLine) { // Line height + the number of wraps we have on a line. - offset += this.getLineValue(startRow).length * textArea.height()!; + offset += this.getLineValue(startLine).length * textArea.height()!; } else { - if (startRow > 0) { - this.setActionsBar(getScreenCoords(startRow - 1)); + if (startLine > 1) { + this.setActionsBar(getScreenCoords(startLine - 1)); return; } - this.setActionsBar(getScreenCoords(startRow + 1)); + this.setActionsBar(getScreenCoords(startLine + 1)); return; } } diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts index b88e0e44591d8..7c4d871c4d73e 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts @@ -24,7 +24,7 @@ export default function(editor: any) { const resize = editor.resize; const throttledResize = throttle(() => { - resize.call(editor); + resize.call(editor, false); // Keep current top line in view when resizing to avoid losing user context const userRow = get(throttledResize, 'topRow', 0); diff --git a/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt b/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt index 88467ab3672cd..7de874c244e74 100644 --- a/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt +++ b/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt @@ -52,3 +52,33 @@ Correctly handle new lines in triple quotes SELECT * FROM "TABLE" """ } +========== +Single quotes escaped special case, start and end +------------------------------------- +{ + "query": "\"test\"" +} +------------------------------------- +{ + "query": "\"test\"" +} +========== +Single quotes escaped special case, start +------------------------------------- +{ + "query": "\"test" +} +------------------------------------- +{ + "query": "\"test" +} +========== +Single quotes escaped special case, end +------------------------------------- +{ + "query": "test\"" +} +------------------------------------- +{ + "query": "test\"" +} diff --git a/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts b/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts index a7f59acf1d77b..0b10938abe704 100644 --- a/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts +++ b/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts @@ -84,6 +84,20 @@ export function expandLiteralStrings(data: string) { // Expand to triple quotes if there are _any_ slashes if (string.match(/\\./)) { const firstDoubleQuoteIdx = string.indexOf('"'); + const lastDoubleQuoteIdx = string.lastIndexOf('"'); + + // Handle a special case where we may have a value like "\"test\"". We don't + // want to expand this to """"test"""" - so we terminate before processing the string + // further if we detect this either at the start or end of the double quote section. + + if (string[firstDoubleQuoteIdx + 1] === '\\' && string[firstDoubleQuoteIdx + 2] === '"') { + return string; + } + + if (string[lastDoubleQuoteIdx - 1] === '"' && string[lastDoubleQuoteIdx - 2] === '\\') { + return string; + } + const colonAndAnySpacing = string.slice(0, firstDoubleQuoteIdx); const rawStringifiedValue = string.slice(firstDoubleQuoteIdx, string.length); // Remove one level of JSON stringification diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/cat.indices.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/cat.indices.json index 45da7f054bfb4..e6ca1fb575396 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/cat.indices.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/cat.indices.json @@ -5,8 +5,15 @@ "bytes": [ "b", "k", + "kb", "m", - "g" + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" ], "local": "__flag__", "master_timeout": "", diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/clear_scroll.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/clear_scroll.json index 55d9673054276..7e6e6692f931b 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/clear_scroll.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/clear_scroll.json @@ -4,8 +4,7 @@ "DELETE" ], "patterns": [ - "_search/scroll", - "_search/scroll/{scroll_id}" + "_search/scroll" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/search-request-body.html#_clear_scroll_api" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/create.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/create.json index 6c0ee8a2425ee..8bbee143c299f 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/create.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/create.json @@ -22,8 +22,7 @@ "POST" ], "patterns": [ - "{indices}/_create/{id}", - "{indices}/{type}/{id}/_create" + "{indices}/_create/{id}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/docs-index_.html" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/delete.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/delete.json index aba84d0a10fc2..0852d8d184831 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/delete.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/delete.json @@ -23,8 +23,7 @@ "DELETE" ], "patterns": [ - "{indices}/_doc/{id}", - "{indices}/{type}/{id}" + "{indices}/_doc/{id}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/docs-delete.html" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/delete_by_query.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/delete_by_query.json index 3867efd814238..2d1636d5f2c02 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/delete_by_query.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/delete_by_query.json @@ -1,7 +1,6 @@ { "delete_by_query": { "url_params": { - "analyzer": "", "analyze_wildcard": "__flag__", "default_operator": [ "AND", @@ -31,6 +30,7 @@ "dfs_query_then_fetch" ], "search_timeout": "", + "size": "", "max_docs": "all documents", "sort": [], "_source": [], diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/exists_source.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/exists_source.json index e96273ffbc083..9ffc4b55f3037 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/exists_source.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/exists_source.json @@ -20,8 +20,7 @@ "HEAD" ], "patterns": [ - "{indices}/_source/{id}", - "{indices}/{type}/{id}/_source" + "{indices}/_source/{id}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/docs-get.html" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/get_script_languages.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/get_script_languages.json new file mode 100644 index 0000000000000..10ea433ca68c5 --- /dev/null +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/get_script_languages.json @@ -0,0 +1,10 @@ +{ + "get_script_languages": { + "methods": [ + "GET" + ], + "patterns": [ + "_script_language" + ] + } +} diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.shrink.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.shrink.json index 31acc86a2fa56..6fbdea0f1244b 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.shrink.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.shrink.json @@ -1,6 +1,7 @@ { "indices.shrink": { "url_params": { + "copy_settings": "__flag__", "timeout": "", "master_timeout": "", "wait_for_active_shards": "" diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.split.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.split.json index 1bfbaa078b796..68f2e338cd201 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.split.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.split.json @@ -1,6 +1,7 @@ { "indices.split": { "url_params": { + "copy_settings": "__flag__", "timeout": "", "master_timeout": "", "wait_for_active_shards": "" diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.validate_query.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.validate_query.json index ceffec26beecc..33720576ef8a3 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.validate_query.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/indices.validate_query.json @@ -28,8 +28,7 @@ ], "patterns": [ "_validate/query", - "{indices}/_validate/query", - "{indices}/{type}/_validate/query" + "{indices}/_validate/query" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/search-validate.html" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/msearch_template.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/msearch_template.json index 0b0ca087b1819..c2f741066bbdb 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/msearch_template.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/msearch_template.json @@ -9,8 +9,7 @@ ], "typed_keys": "__flag__", "max_concurrent_searches": "", - "rest_total_hits_as_int": "__flag__", - "ccs_minimize_roundtrips": "__flag__" + "rest_total_hits_as_int": "__flag__" }, "methods": [ "GET", diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/nodes.hot_threads.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/nodes.hot_threads.json index b8aa5dd4ca711..b3cbbe80e0d00 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/nodes.hot_threads.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/nodes.hot_threads.json @@ -17,13 +17,7 @@ ], "patterns": [ "_nodes/hot_threads", - "_nodes/{nodes}/hot_threads", - "_cluster/nodes/hotthreads", - "_cluster/nodes/{nodes}/hotthreads", - "_nodes/hotthreads", - "_nodes/{nodes}/hotthreads", - "_cluster/nodes/hot_threads", - "_cluster/nodes/{nodes}/hot_threads" + "_nodes/{nodes}/hot_threads" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/cluster-nodes-hot-threads.html" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/rank_eval.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/rank_eval.json index 620f1c629d959..c2bed081124a8 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/rank_eval.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/rank_eval.json @@ -8,6 +8,10 @@ "closed", "none", "all" + ], + "search_type": [ + "query_then_fetch", + "dfs_query_then_fetch" ] }, "methods": [ diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/scroll.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/scroll.json index 3e959b9630e98..4ce82a2c25e0e 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/scroll.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/scroll.json @@ -10,8 +10,7 @@ "POST" ], "patterns": [ - "_search/scroll", - "_search/scroll/{scroll_id}" + "_search/scroll" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/search-request-body.html#request-body-search-scroll" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/search_template.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/search_template.json index 582ecab1dd614..cf5a5c5f32db3 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/search_template.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/search_template.json @@ -22,8 +22,7 @@ "explain": "__flag__", "profile": "__flag__", "typed_keys": "__flag__", - "rest_total_hits_as_int": "__flag__", - "ccs_minimize_roundtrips": "__flag__" + "rest_total_hits_as_int": "__flag__" }, "methods": [ "GET", diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/update.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/update.json index 4e103b0af2195..43945dfada35c 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/update.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/update.json @@ -21,8 +21,7 @@ "POST" ], "patterns": [ - "{indices}/_update/{id}", - "{indices}/{type}/{id}/_update" + "{indices}/_update/{id}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/docs-update.html" } diff --git a/src/legacy/core_plugins/console/server/api_server/spec/generated/update_by_query.json b/src/legacy/core_plugins/console/server/api_server/spec/generated/update_by_query.json index 739ea16888146..393197949e86c 100644 --- a/src/legacy/core_plugins/console/server/api_server/spec/generated/update_by_query.json +++ b/src/legacy/core_plugins/console/server/api_server/spec/generated/update_by_query.json @@ -32,6 +32,7 @@ "dfs_query_then_fetch" ], "search_timeout": "", + "size": "", "max_docs": "all documents", "sort": [], "_source": [], diff --git a/src/legacy/core_plugins/console/server/api_server/spec/overrides/clear_scroll.json b/src/legacy/core_plugins/console/server/api_server/spec/overrides/clear_scroll.json new file mode 100644 index 0000000000000..e9d4a6cee54ce --- /dev/null +++ b/src/legacy/core_plugins/console/server/api_server/spec/overrides/clear_scroll.json @@ -0,0 +1,7 @@ +{ + "clear_scroll": { + "data_autocomplete_rules": { + "scroll_id": "" + } + } +} diff --git a/src/legacy/core_plugins/console/server/api_server/spec/overrides/create.json b/src/legacy/core_plugins/console/server/api_server/spec/overrides/create.json deleted file mode 100644 index 0bbf456245c84..0000000000000 --- a/src/legacy/core_plugins/console/server/api_server/spec/overrides/create.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "create": { - "url_params": { - "timeout": "1m", - "ttl": "5m", - "version": "1" - }, - "methods": [ - "PUT", - "POST" - ], - "patterns": [ - "{indices}/{type}/{id}/_create" - ] - } -} diff --git a/src/legacy/core_plugins/data/public/search/fetch/components/__snapshots__/shard_failure_table.test.tsx.snap b/src/legacy/core_plugins/data/public/search/fetch/components/__snapshots__/shard_failure_table.test.tsx.snap index 55e2c63f608d4..257513f20fa94 100644 --- a/src/legacy/core_plugins/data/public/search/fetch/components/__snapshots__/shard_failure_table.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/search/fetch/components/__snapshots__/shard_failure_table.test.tsx.snap @@ -72,5 +72,6 @@ exports[`ShardFailureTable renders matching snapshot given valid properties 1`] }, } } + tableLayout="fixed" /> `; diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.tsx.snap b/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.tsx.snap index 632fe63e9e148..278811ca85df9 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.tsx.snap +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.tsx.snap @@ -135,11 +135,7 @@ exports[`renders ControlsTab 1`] = ` > ) => void; handleParentChange: (controlIndex: number, event: ChangeEvent) => void; - parentCandidates: EuiSelectProps['options']; + parentCandidates: React.ComponentProps['options']; deps: InputControlVisDependencies; } diff --git a/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap b/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap index 31c221b36e2b2..99482a4be2d7b 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap +++ b/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/list_control.test.tsx.snap @@ -8,10 +8,7 @@ exports[`disableMsg 1`] = ` label="list control" > diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/area.js b/src/legacy/core_plugins/kbn_vislib_vis_types/public/area.js index 049544b504918..fd13067c84cc0 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/area.js +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/area.js @@ -33,7 +33,7 @@ import { getConfigCollections, } from './utils/collections'; import { getAreaOptionTabs, countLabel } from './utils/common_config'; -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import { vislibVisController } from './controller'; export const areaDefinition = { @@ -117,7 +117,7 @@ export const areaDefinition = { value: 10, width: 1, style: ThresholdLineStyles.FULL, - color: palettes.euiPaletteColorBlind.colors[9], + color: euiPaletteColorBlind()[9], }, labels: {}, }, diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx index 276e765ae7fe6..947c7ae7e6e36 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx @@ -48,6 +48,10 @@ function ColorRanges({ const validateRange = useCallback( ({ from, to }, index) => { + if (!colorsRange[index]) { + return [false, false]; + } + const leftBound = index === 0 ? -Infinity : colorsRange[index - 1].to || 0; const isFromValid = from >= leftBound; const isToValid = to >= from; diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/histogram.js b/src/legacy/core_plugins/kbn_vislib_vis_types/public/histogram.js index fdc18f5bfa0e6..bc017b5a1a871 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/histogram.js +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/histogram.js @@ -32,7 +32,7 @@ import { getConfigCollections, } from './utils/collections'; import { getAreaOptionTabs, countLabel } from './utils/common_config'; -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import { vislibVisController } from './controller'; export const histogramDefinition = { @@ -120,7 +120,7 @@ export const histogramDefinition = { value: 10, width: 1, style: ThresholdLineStyles.FULL, - color: palettes.euiPaletteColorBlind.colors[9], + color: euiPaletteColorBlind()[9], }, }, }, diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/horizontal_bar.js b/src/legacy/core_plugins/kbn_vislib_vis_types/public/horizontal_bar.js index 15bbf9c01cd77..ee3570314618a 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/horizontal_bar.js +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/horizontal_bar.js @@ -32,7 +32,7 @@ import { getConfigCollections, } from './utils/collections'; import { getAreaOptionTabs, countLabel } from './utils/common_config'; -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import { vislibVisController } from './controller'; export const horizontalBarDefinition = { @@ -119,7 +119,7 @@ export const horizontalBarDefinition = { value: 10, width: 1, style: ThresholdLineStyles.FULL, - color: palettes.euiPaletteColorBlind.colors[9], + color: euiPaletteColorBlind()[9], }, }, }, diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/line.js b/src/legacy/core_plugins/kbn_vislib_vis_types/public/line.js index a3fb874b5aa1b..d6d075f452fed 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/line.js +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/line.js @@ -32,7 +32,7 @@ import { InterpolationModes, getConfigCollections, } from './utils/collections'; -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import { getAreaOptionTabs, countLabel } from './utils/common_config'; import { vislibVisController } from './controller'; @@ -118,7 +118,7 @@ export const lineDefinition = { value: 10, width: 1, style: ThresholdLineStyles.FULL, - color: palettes.euiPaletteColorBlind.colors[9], + color: euiPaletteColorBlind()[9], }, }, }, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts new file mode 100644 index 0000000000000..df2dbfd54c130 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts @@ -0,0 +1,118 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('../', () => ({ + DashboardConstants: { + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + }, +})); + +jest.mock('../legacy_imports', () => { + return { + absoluteToParsedUrl: jest.fn(() => { + return { + basePath: '/pep', + appId: 'kibana', + appPath: '/dashboard?addEmbeddableType=lens&addEmbeddableId=123eb456cd&x=1&y=2&z=3', + hostname: 'localhost', + port: 5601, + protocol: 'http:', + addQueryParameter: () => {}, + getAbsoluteUrl: () => { + return 'http://localhost:5601/pep/app/kibana#/dashboard?addEmbeddableType=lens&addEmbeddableId=123eb456cd&x=1&y=2&z=3'; + }, + }; + }), + }; +}); + +import { + addEmbeddableToDashboardUrl, + getLensUrlFromDashboardAbsoluteUrl, + getUrlVars, +} from '../np_ready/url_helper'; + +describe('Dashboard URL Helper', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('addEmbeddableToDashboardUrl', () => { + const id = '123eb456cd'; + const type = 'lens'; + const urlVars = { + x: '1', + y: '2', + z: '3', + }; + const basePath = '/pep'; + const url = + "http://localhost:5601/pep/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(addEmbeddableToDashboardUrl(url, basePath, id, urlVars, type)).toEqual( + `http://localhost:5601/pep/app/kibana#/dashboard?addEmbeddableType=${type}&addEmbeddableId=${id}&x=1&y=2&z=3` + ); + }); + + it('getUrlVars', () => { + let url = + "http://localhost:5601/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getUrlVars(url)).toEqual({ + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))', + _a: "(description:'',filters:!()", + }); + url = 'http://mybusiness.mydomain.com/app/kibana#/dashboard?x=y&y=z'; + expect(getUrlVars(url)).toEqual({ + x: 'y', + y: 'z', + }); + url = 'http://localhost:5601/app/kibana#/dashboard/777182'; + expect(getUrlVars(url)).toEqual({}); + url = + 'http://localhost:5601/app/kibana#/dashboard/777182?title=Some%20Dashboard%20With%20Spaces'; + expect(getUrlVars(url)).toEqual({ title: 'Some Dashboard With Spaces' }); + }); + + it('getLensUrlFromDashboardAbsoluteUrl', () => { + const id = '1244'; + const basePath = '/wev'; + let url = + "http://localhost:5601/wev/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getLensUrlFromDashboardAbsoluteUrl(url, basePath, id)).toEqual( + 'http://localhost:5601/wev/app/kibana#/lens/edit/1244' + ); + + url = + "http://localhost:5601/wev/app/kibana#/dashboard/625357282?_a=(description:'',filters:!()&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"; + expect(getLensUrlFromDashboardAbsoluteUrl(url, basePath, id)).toEqual( + 'http://localhost:5601/wev/app/kibana#/lens/edit/1244' + ); + + url = 'http://myserver.mydomain.com:5601/wev/app/kibana#/dashboard/777182'; + expect(getLensUrlFromDashboardAbsoluteUrl(url, basePath, id)).toEqual( + 'http://myserver.mydomain.com:5601/wev/app/kibana#/lens/edit/1244' + ); + + url = + "http://localhost:5601/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getLensUrlFromDashboardAbsoluteUrl(url, '', id)).toEqual( + 'http://localhost:5601/app/kibana#/lens/edit/1244' + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts index ec0913e5fb3e7..ba01919431080 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts @@ -67,3 +67,4 @@ export { IInjector } from 'ui/chrome'; export { SavedObjectLoader } from 'ui/saved_objects'; export { VISUALIZE_EMBEDDABLE_TYPE } from '../visualize_embeddable'; export { registerTimefilterWithGlobalStateFactory } from 'ui/timefilter/setup_router'; +export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts index 2a5dedab98151..7f7bf7cf47bda 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/application.ts @@ -86,11 +86,9 @@ export const renderApp = (element: HTMLElement, appBasePath: string, deps: Rende }; }; -const mainTemplate = (basePath: string) => `
+const mainTemplate = (basePath: string) => `
-
-
-`; +
`; const moduleName = 'app/dashboard'; @@ -98,7 +96,7 @@ const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; function mountDashboardApp(appBasePath: string, element: HTMLElement) { const mountpoint = document.createElement('div'); - mountpoint.setAttribute('style', 'height: 100%'); + mountpoint.setAttribute('class', 'kbnLocalApplicationWrapper'); // eslint-disable-next-line mountpoint.innerHTML = mainTemplate(appBasePath); // bootstrap angular into detached element and attach it later to diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index 2523d1e60a741..2706b588a2ec4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -37,7 +37,6 @@ import { KbnUrl, SavedObjectSaveOpts, unhashUrl, - VISUALIZE_EMBEDDABLE_TYPE, } from '../legacy_imports'; import { FilterStateManager } from '../../../../data/public'; import { @@ -334,13 +333,12 @@ export class DashboardAppController { // This code needs to be replaced with a better mechanism for adding new embeddables of // any type from the add panel. Likely this will happen via creating a visualization "inline", // without navigating away from the UX. - if ($routeParams[DashboardConstants.NEW_VISUALIZATION_ID_PARAM]) { - container.addSavedObjectEmbeddable( - VISUALIZE_EMBEDDABLE_TYPE, - $routeParams[DashboardConstants.NEW_VISUALIZATION_ID_PARAM] - ); - kbnUrl.removeParam(DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); - kbnUrl.removeParam(DashboardConstants.NEW_VISUALIZATION_ID_PARAM); + if ($routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]) { + const type = $routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]; + const id = $routeParams[DashboardConstants.ADD_EMBEDDABLE_ID]; + container.addSavedObjectEmbeddable(type, id); + kbnUrl.removeParam(DashboardConstants.ADD_EMBEDDABLE_TYPE); + kbnUrl.removeParam(DashboardConstants.ADD_EMBEDDABLE_ID); } } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts index b76b3f309874a..fe42e07912799 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts @@ -19,9 +19,10 @@ export const DashboardConstants = { ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', - NEW_VISUALIZATION_ID_PARAM: 'addVisualization', LANDING_PAGE_PATH: '/dashboards', CREATE_NEW_DASHBOARD_URL: '/dashboard', + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', }; export function createDashboardEditUrl(id: string) { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap index b2f004568841a..2a9a793ba43c4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap @@ -9,6 +9,7 @@ exports[`after fetch hideWriteControls 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -16,13 +17,15 @@ exports[`after fetch hideWriteControls 1`] = ` +

-

+ } />
@@ -63,6 +66,7 @@ exports[`after fetch initialFilter 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="my dashboard" listingLimit={1000} noItemsFragment={ @@ -114,13 +118,15 @@ exports[`after fetch initialFilter 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } />
@@ -161,6 +167,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -212,13 +219,15 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } />
@@ -259,6 +268,7 @@ exports[`after fetch renders table rows 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1000} noItemsFragment={ @@ -310,13 +320,15 @@ exports[`after fetch renders table rows 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -357,6 +369,7 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -408,13 +421,15 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -455,6 +470,7 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1000} noItemsFragment={ @@ -506,13 +522,15 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js index 827fe6eabe784..30bf940069fb7 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js @@ -42,6 +42,7 @@ export class DashboardListing extends React.Component { return ( +

-

+ } /> @@ -90,12 +91,12 @@ export class DashboardListing extends React.Component { +

-

+ } body={ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap index 6def1b1a198b8..e76f65c45e428 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/clone_modal.test.js.snap @@ -30,11 +30,8 @@ exports[`renders DashboardCloneModal 1`] = ` diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts new file mode 100644 index 0000000000000..2e360567c4653 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { parse } from 'url'; +import { absoluteToParsedUrl } from '../legacy_imports'; +import { DashboardConstants } from './dashboard_constants'; +/** + * Return query params from URL + * @param url given url + */ +export function getUrlVars(url: string): Record { + const vars: Record = {}; + // @ts-ignore + url.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(_, key, value) { + // @ts-ignore + vars[key] = decodeURIComponent(value); + }); + return vars; +} + +/** * + * Returns dashboard URL with added embeddableType and embeddableId query params + * eg. + * input: url: http://localhost:5601/lib/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345, embeddableType: 'lens' + * output: http://localhost:5601/lib/app/kibana#dashboard?addEmbeddableType=lens&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) + * @param url dasbhoard absolute url + * @param embeddableId id of the saved visualization + * @param basePath current base path + * @param urlVars url query params (optional) + * @param embeddableType 'lens' or 'visualization' (optional, default is 'lens') + */ +export function addEmbeddableToDashboardUrl( + url: string | undefined, + basePath: string, + embeddableId: string, + urlVars?: Record, + embeddableType?: string +): string | null { + if (!url) { + return null; + } + const dashboardUrl = getUrlWithoutQueryParams(url); + const dashboardParsedUrl = absoluteToParsedUrl(dashboardUrl, basePath); + if (urlVars) { + const keys = Object.keys(urlVars).sort(); + keys.forEach(key => { + dashboardParsedUrl.addQueryParameter(key, urlVars[key]); + }); + } + dashboardParsedUrl.addQueryParameter( + DashboardConstants.ADD_EMBEDDABLE_TYPE, + embeddableType || 'lens' + ); + dashboardParsedUrl.addQueryParameter(DashboardConstants.ADD_EMBEDDABLE_ID, embeddableId); + return dashboardParsedUrl.getAbsoluteUrl(); +} + +/** + * Return Lens URL from dashboard absolute URL + * @param dashboardAbsoluteUrl + * @param basePath current base path + * @param id Lens id + */ +export function getLensUrlFromDashboardAbsoluteUrl( + dashboardAbsoluteUrl: string | undefined | null, + basePath: string | null | undefined, + id: string +): string | null { + if (!dashboardAbsoluteUrl || basePath === null || basePath === undefined) { + return null; + } + const { host, protocol } = parse(dashboardAbsoluteUrl); + return `${protocol}//${host}${basePath}/app/kibana#/lens/edit/${id}`; +} + +/** + * Returns the portion of the URL without query params + * eg. + * input: http://localhost:5601/lib/app/kibana#/dashboard?param1=x¶m2=y¶m3=z + * output:http://localhost:5601/lib/app/kibana#/dashboard + * input: http://localhost:5601/lib/app/kibana#/dashboard/39292992?param1=x¶m2=y¶m3=z + * output: http://localhost:5601/lib/app/kibana#/dashboard/39292992 + * @param url url to parse + */ +function getUrlWithoutQueryParams(url: string): string { + return url.split('?')[0]; +} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts index a6c6d91084625..6054b9f8d03c5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts @@ -74,9 +74,9 @@ function fetchContextProvider(indexPatterns: IndexPatternsContract) { const searchSource = await createSearchSource(indexPattern, filters); const sortDirToApply = type === 'successors' ? sortDir : reverseSortDir(sortDir); - const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor._source[timeField]) : ''; + const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; const timeValueMillis = - nanos !== '' ? convertIsoToMillis(anchor._source[timeField]) : anchor.sort[0]; + nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis, type, sortDir); let documents: EsHitRecordList = []; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_search_after.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_search_after.ts index 3f9bf255aefa9..d4ee9e0e0f287 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_search_after.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_search_after.ts @@ -39,14 +39,14 @@ export function getEsQuerySearchAfter( const afterTimeRecIdx = type === 'successors' && documents.length ? documents.length - 1 : 0; const afterTimeDoc = documents[afterTimeRecIdx]; const afterTimeValue = nanoSeconds - ? convertIsoToNanosAsStr(afterTimeDoc._source[timeFieldName]) + ? convertIsoToNanosAsStr(afterTimeDoc.fields[timeFieldName][0]) : afterTimeDoc.sort[0]; return [afterTimeValue, afterTimeDoc.sort[1]]; } // if data_nanos adapt timestamp value for sorting, since numeric value was rounded by browser // ES search_after also works when number is provided as string return [ - nanoSeconds ? convertIsoToNanosAsStr(anchor._source[timeFieldName]) : anchor.sort[0], + nanoSeconds ? convertIsoToNanosAsStr(anchor.fields[timeFieldName][0]) : anchor.sort[0], anchor.sort[1], ]; } diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js index 314ddf2196f06..c7aa5b0f5b2f9 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js @@ -363,6 +363,11 @@ class TutorialUi extends React.Component { ); } + let icon = this.state.tutorial.euiIconType; + if (icon && icon.includes('/')) { + icon = this.props.addBasePath(icon); + } + const instructions = this.getInstructions(); content = (
@@ -371,7 +376,7 @@ class TutorialUi extends React.Component { description={this.props.replaceTemplateStrings(this.state.tutorial.longDescription)} previewUrl={previewUrl} exportedFieldsUrl={exportedFieldsUrl} - iconType={this.state.tutorial.euiIconType} + iconType={icon} isBeta={this.state.tutorial.isBeta} /> diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js index 06da6f35ee42e..697c1b0468cd1 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js @@ -129,7 +129,7 @@ class TutorialDirectoryUi extends React.Component { let tutorialCards = tutorialConfigs.map(tutorialConfig => { // add base path to SVG based icons let icon = tutorialConfig.euiIconType; - if (icon != null && icon.includes('/')) { + if (icon && icon.includes('/')) { icon = this.props.addBasePath(icon); } diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png new file mode 100644 index 0000000000000..100a8b6ae367c Binary files /dev/null and b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png differ diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg new file mode 100644 index 0000000000000..ad0cb64b161dd --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/index.scss b/src/legacy/core_plugins/kibana/public/index.scss index 3b49af9a4a6a6..dfe4aa1fd3b9f 100644 --- a/src/legacy/core_plugins/kibana/public/index.scss +++ b/src/legacy/core_plugins/kibana/public/index.scss @@ -26,6 +26,9 @@ // Management styles @import './management/index'; +// Local application mount wrapper styles +@import 'local_application_service/index'; + // Dashboard styles // MUST STAY AT THE BOTTOM BECAUSE OF DARK THEME IMPORTS @import './dashboard/index'; diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/_index.scss b/src/legacy/core_plugins/kibana/public/local_application_service/_index.scss new file mode 100644 index 0000000000000..12cc1444101e7 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/local_application_service/_index.scss @@ -0,0 +1 @@ +@import 'local_application_service'; diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/_local_application_service.scss b/src/legacy/core_plugins/kibana/public/local_application_service/_local_application_service.scss new file mode 100644 index 0000000000000..33a6100c43975 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/local_application_service/_local_application_service.scss @@ -0,0 +1,5 @@ +.kbnLocalApplicationWrapper { + display: flex; + flex-direction: column; + flex-grow: 1; +} diff --git a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts index c09995caab669..d52bec8304ff9 100644 --- a/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts +++ b/src/legacy/core_plugins/kibana/public/local_application_service/local_application_service.ts @@ -56,7 +56,7 @@ export class LocalApplicationService { outerAngularWrapperRoute: true, reloadOnSearch: false, reloadOnUrl: false, - template: `
`, + template: `
`, controller($scope: IScope) { const element = document.getElementById(wrapperElementId)!; let unmountHandler: AppUnmount | null = null; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_index_pattern/components/header/__jest__/__snapshots__/header.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_index_pattern/components/header/__jest__/__snapshots__/header.test.js.snap index 11c41425a0bb5..f2fb17cdb0d60 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_index_pattern/components/header/__jest__/__snapshots__/header.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_index_pattern/components/header/__jest__/__snapshots__/header.test.js.snap @@ -78,11 +78,8 @@ exports[`Header should mark the input as invalid 1`] = ` labelType="label" > `; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap index 4716fb8f77633..2da4d84463b29 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/scripted_fields_table/components/table/__jest__/__snapshots__/table.test.js.snap @@ -76,6 +76,7 @@ exports[`Table should render normally 1`] = ` } responsive={true} sorting={true} + tableLayout="fixed" /> `; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/__snapshots__/add_filter.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/__snapshots__/add_filter.test.js.snap index 432c57d4f473d..879ea555d3300 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/__snapshots__/add_filter.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/source_filters_table/components/add_filter/__jest__/__snapshots__/add_filter.test.js.snap @@ -6,9 +6,7 @@ exports[`AddFilter should ignore strings with just spaces 1`] = ` grow={10} > `; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap index 2aaa291f6122b..4ba0fe480ac42 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap @@ -71,6 +71,7 @@ exports[`ObjectsTable delete should show a confirm modal 1`] = ` pagination={true} responsive={true} sorting={false} + tableLayout="fixed" /> `; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap index ace06e0420a7c..34ce8394232ed 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap @@ -115,6 +115,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` } } responsive={true} + tableLayout="fixed" /> @@ -445,6 +446,7 @@ exports[`Flyout legacy conflicts should allow conflict resolution 1`] = ` } } responsive={true} + tableLayout="fixed" /> diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap index 941a0ffded820..c1241d5d7c1e5 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap @@ -154,6 +154,7 @@ exports[`Relationships should render dashboards normally 1`] = ` ], } } + tableLayout="fixed" />
@@ -368,6 +369,7 @@ exports[`Relationships should render index patterns normally 1`] = ` ], } } + tableLayout="fixed" /> @@ -533,6 +535,7 @@ exports[`Relationships should render searches normally 1`] = ` ], } } + tableLayout="fixed" /> @@ -693,6 +696,7 @@ exports[`Relationships should render visualizations normally 1`] = ` ], } } + tableLayout="fixed" /> diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap index daac04d07da28..805131042f385 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/__jest__/__snapshots__/table.test.js.snap @@ -203,6 +203,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "onSelectionChange": [Function], } } + tableLayout="fixed" />
@@ -410,6 +411,7 @@ exports[`Table should render normally 1`] = ` "onSelectionChange": [Function], } } + tableLayout="fixed" /> diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index 10d165d0d69c4..eef8f3fc93d90 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -60,6 +60,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": Array [ "default_value", ], + "deprecation": undefined, "description": "Description for Test array setting", "displayName": "Test array setting", "isCustom": undefined, @@ -79,6 +80,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "elasticsearch", ], "defVal": true, + "deprecation": undefined, "description": "Description for Test boolean setting", "displayName": "Test boolean setting", "isCustom": undefined, @@ -100,6 +102,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test custom string setting", "displayName": "Test custom string setting", "isCustom": undefined, @@ -119,6 +122,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test image setting", "displayName": "Test image setting", "isCustom": undefined, @@ -140,6 +144,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": "{ \\"foo\\": \\"bar\\" }", + "deprecation": undefined, "description": "Description for overridden json", "displayName": "An overridden json", "isCustom": undefined, @@ -159,6 +164,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 1234, + "deprecation": undefined, "description": "Description for overridden number", "displayName": "An overridden number", "isCustom": undefined, @@ -178,6 +184,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for overridden select setting", "displayName": "Test overridden select setting", "isCustom": undefined, @@ -201,6 +208,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "foo", + "deprecation": undefined, "description": "Description for overridden string", "displayName": "An overridden string", "isCustom": undefined, @@ -220,6 +228,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "{\\"foo\\": \\"bar\\"}", + "deprecation": undefined, "description": "Description for Test json setting", "displayName": "Test json setting", "isCustom": undefined, @@ -239,6 +248,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "", + "deprecation": undefined, "description": "Description for Test markdown setting", "displayName": "Test markdown setting", "isCustom": undefined, @@ -258,6 +268,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 5, + "deprecation": undefined, "description": "Description for Test number setting", "displayName": "Test number setting", "isCustom": undefined, @@ -277,6 +288,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for Test select setting", "displayName": "Test select setting", "isCustom": undefined, @@ -300,6 +312,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -345,6 +358,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": Array [ "default_value", ], + "deprecation": undefined, "description": "Description for Test array setting", "displayName": "Test array setting", "isCustom": undefined, @@ -364,6 +378,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "elasticsearch", ], "defVal": true, + "deprecation": undefined, "description": "Description for Test boolean setting", "displayName": "Test boolean setting", "isCustom": undefined, @@ -385,6 +400,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test custom string setting", "displayName": "Test custom string setting", "isCustom": undefined, @@ -404,6 +420,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test image setting", "displayName": "Test image setting", "isCustom": undefined, @@ -425,6 +442,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": "{ \\"foo\\": \\"bar\\" }", + "deprecation": undefined, "description": "Description for overridden json", "displayName": "An overridden json", "isCustom": undefined, @@ -444,6 +462,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 1234, + "deprecation": undefined, "description": "Description for overridden number", "displayName": "An overridden number", "isCustom": undefined, @@ -463,6 +482,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for overridden select setting", "displayName": "Test overridden select setting", "isCustom": undefined, @@ -486,6 +506,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "foo", + "deprecation": undefined, "description": "Description for overridden string", "displayName": "An overridden string", "isCustom": undefined, @@ -505,6 +526,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "{\\"foo\\": \\"bar\\"}", + "deprecation": undefined, "description": "Description for Test json setting", "displayName": "Test json setting", "isCustom": undefined, @@ -524,6 +546,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "", + "deprecation": undefined, "description": "Description for Test markdown setting", "displayName": "Test markdown setting", "isCustom": undefined, @@ -543,6 +566,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 5, + "deprecation": undefined, "description": "Description for Test number setting", "displayName": "Test number setting", "isCustom": undefined, @@ -562,6 +586,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for Test select setting", "displayName": "Test select setting", "isCustom": undefined, @@ -585,6 +610,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -705,6 +731,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -748,6 +775,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -886,6 +914,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -929,6 +958,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap index ae168e76d359b..f4d20b4565880 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap @@ -50,10 +50,8 @@ exports[`Field for array setting should render as read only if saving is disable > maxSize.length); @@ -565,6 +568,36 @@ export class Field extends PureComponent { renderDescription(setting) { let description; + let deprecation; + + if (setting.deprecation) { + const { links } = npStart.core.docLinks; + + deprecation = ( + <> + + { + window.open(links.management[setting.deprecation.docLinksKey], '_blank'); + }} + onClickAriaLabel={i18n.translate( + 'kbn.management.settings.field.deprecationClickAreaLabel', + { + defaultMessage: 'Click to view deprecation documentation for {settingName}.', + values: { + settingName: setting.name, + }, + } + )} + > + Deprecated + + + + + ); + } if (React.isValidElement(setting.description)) { description = setting.description; @@ -582,6 +615,7 @@ export class Field extends PureComponent { return ( + {deprecation} {description} {this.renderDefaultValue(setting)} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js index 74bb0e25ff52e..07ce6f84d2bb6 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js @@ -72,10 +72,9 @@ const settings = { defVal: null, isCustom: false, isOverridden: false, - options: { + validation: { maxSize: { length: 1000, - displayName: '1 kB', description: 'Description for 1 kB', }, }, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js index 791f9e400b407..6efb89cfba2b2 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js @@ -43,12 +43,14 @@ export function toEditableConfig({ def, name, value, isCustom, isOverridden }) { defVal: def.value, type: getValType(def, value), description: def.description, - validation: def.validation - ? { - regex: new RegExp(def.validation.regexString), - message: def.validation.message, - } - : undefined, + deprecation: def.deprecation, + validation: + def.validation && def.validation.regexString + ? { + regex: new RegExp(def.validation.regexString), + message: def.validation.message, + } + : def.validation, options: def.options, optionLabels: def.optionLabels, requiresPageReload: !!def.requiresPageReload, diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts index dcd68a26743ab..222b035708976 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts @@ -63,9 +63,8 @@ export const renderApp = async ( return () => $injector.get('$rootScope').$destroy(); }; -const mainTemplate = (basePath: string) => `
+const mainTemplate = (basePath: string) => `
-
`; @@ -75,7 +74,7 @@ const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; function mountVisualizeApp(appBasePath: string, element: HTMLElement) { const mountpoint = document.createElement('div'); - mountpoint.setAttribute('style', 'height: 100%'); + mountpoint.setAttribute('class', 'kbnLocalApplicationWrapper'); mountpoint.innerHTML = mainTemplate(appBasePath); // bootstrap angular into detached element and attach it later to // make angular-within-angular possible diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index ed9bec9db4112..64653730473cd 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -35,8 +35,8 @@ import { unhashUrl } from '../../../../../../../plugins/kibana_utils/public'; import { initVisEditorDirective } from './visualization_editor'; import { initVisualizationDirective } from './visualization'; - import { + VISUALIZE_EMBEDDABLE_TYPE, subscribeWithScope, absoluteToParsedUrl, KibanaParsedUrl, @@ -588,7 +588,11 @@ function VisualizeAppController( getBasePath() ); dashboardParsedUrl.addQueryParameter( - DashboardConstants.NEW_VISUALIZATION_ID_PARAM, + DashboardConstants.ADD_EMBEDDABLE_TYPE, + VISUALIZE_EMBEDDABLE_TYPE + ); + dashboardParsedUrl.addQueryParameter( + DashboardConstants.ADD_EMBEDDABLE_ID, savedVis.id ); kbnUrl.change(dashboardParsedUrl.appPath); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js index 840e647edcc86..b770625cd3d70 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js @@ -36,6 +36,7 @@ class VisualizeListingTable extends Component { const { visualizeCapabilities, uiSettings, toastNotifications } = getServices(); return ( +

-

+ } />
@@ -130,12 +131,12 @@ class VisualizeListingTable extends Component { +

-

+ } body={ diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap index 0b44c7dc4e860..c75fd2096feab 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -234,6 +234,26 @@ exports[`NewVisModal filter for visualization types should render as expected 1` /> +
+ +
@@ -565,6 +585,26 @@ exports[`NewVisModal filter for visualization types should render as expected 1` /> +
+ +
@@ -835,6 +875,26 @@ exports[`NewVisModal filter for visualization types should render as expected 1` /> +
+ +
@@ -1139,12 +1199,18 @@ exports[`NewVisModal filter for visualization types should render as expected 1` data-test-subj="filterVisType" fullWidth={true} incremental={false} + isClearable={true} isLoading={false} onChange={[Function]} placeholder="Filter" value="with" > @@ -1209,6 +1280,50 @@ exports[`NewVisModal filter for visualization types should render as expected 1` +
+ + + + + +
@@ -1594,7 +1709,7 @@ exports[`NewVisModal filter for visualization types should render as expected 1` />
@@ -2775,12 +2890,14 @@ exports[`NewVisModal should render as expected 1`] = ` data-test-subj="filterVisType" fullWidth={true} incremental={false} + isClearable={true} isLoading={false} onChange={[Function]} placeholder="Filter" value="" > @@ -3218,7 +3336,7 @@ exports[`NewVisModal should render as expected 1`] = ` /> diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx index 2005133e6d03e..0ef1b711eafc8 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx @@ -144,7 +144,7 @@ describe('NewVisModal', () => { expect(window.location.assign).toBeCalledWith('#/visualize/create?type=vis&foo=true&bar=42'); }); - it('closes if visualization with aliasUrl and addToDashboard in editorParams', () => { + it('closes and redirects properly if visualization with aliasUrl and addToDashboard in editorParams', () => { const onClose = jest.fn(); window.location.assign = jest.fn(); const wrapper = mountWithIntl( @@ -160,7 +160,7 @@ describe('NewVisModal', () => { ); const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]'); visButton.simulate('click'); - expect(window.location.assign).toBeCalledWith('testbasepath/aliasUrl'); + expect(window.location.assign).toBeCalledWith('testbasepath/aliasUrl?addToDashboard'); expect(onClose).toHaveBeenCalled(); }); }); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx index 9e8f46407f591..082fc3bc36b6b 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx @@ -143,15 +143,18 @@ class NewVisModal extends React.Component { stage: 'production', }, ]} - addBasePath={(url: string) => `testbasepath${url}`} + onPromotionClicked={() => {}} /> ) ).toMatchInlineSnapshot(` @@ -60,9 +60,9 @@ describe('NewVisHelp', () => {

Do it now! diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx index 107cbc0e754b5..2f7effb7a33c8 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx @@ -21,10 +21,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { EuiText, EuiButton } from '@elastic/eui'; import { VisTypeAliasListEntry } from './type_selection'; +import { VisTypeAlias } from '../../../../../../visualizations/public'; interface Props { promotedTypes: VisTypeAliasListEntry[]; - addBasePath: (path: string) => string; + onPromotionClicked: (visType: VisTypeAlias) => void; } export function NewVisHelp(props: Props) { @@ -42,7 +43,7 @@ export function NewVisHelp(props: Props) { {t.promotion!.description}

props.onPromotionClicked(t)} fill size="s" iconType="popout" diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx index 28cafde45a714..44da7cc8f2c45 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx @@ -154,7 +154,7 @@ class TypeSelection extends React.Component t.promotion)} - addBasePath={this.props.addBasePath} + onPromotionClicked={this.props.onVisTypeSelected} /> )} diff --git a/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts index fc91742c53cca..b7a3a0f000d72 100644 --- a/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts @@ -379,6 +379,7 @@ export class VisualizeEmbeddable extends Embeddable `; diff --git a/src/legacy/core_plugins/telemetry/common/constants.ts b/src/legacy/core_plugins/telemetry/common/constants.ts index 7e366676a8565..cb4ff79969a32 100644 --- a/src/legacy/core_plugins/telemetry/common/constants.ts +++ b/src/legacy/core_plugins/telemetry/common/constants.ts @@ -75,3 +75,9 @@ export const UI_METRIC_USAGE_TYPE = 'ui_metric'; * Link to Advanced Settings. */ export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; + +/** + * The type name used within the Monitoring index to publish management stats. + * @type {string} + */ +export const KIBANA_MANAGEMENT_STATS_TYPE = 'management'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/index.ts index 2f2a53278117b..04ee4773cd60d 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/index.ts +++ b/src/legacy/core_plugins/telemetry/server/collectors/index.ts @@ -22,3 +22,4 @@ export { registerTelemetryUsageCollector } from './usage'; export { registerUiMetricUsageCollector } from './ui_metric'; export { registerLocalizationUsageCollector } from './localization'; export { registerTelemetryPluginUsageCollector } from './telemetry_plugin'; +export { registerManagementUsageCollector } from './management'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts new file mode 100644 index 0000000000000..979bbed3765e2 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerManagementUsageCollector } from './telemetry_management_collector'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts b/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts new file mode 100644 index 0000000000000..f45cf7fc6bb33 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server } from 'hapi'; +import { size } from 'lodash'; +import { KIBANA_MANAGEMENT_STATS_TYPE } from '../../../common/constants'; +import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { SavedObjectsClient } from '../../../../../../core/server'; + +export type UsageStats = Record; + +export async function getTranslationCount(loader: any, locale: string): Promise { + const translations = await loader.getTranslationsByLocale(locale); + return size(translations.messages); +} + +export function createCollectorFetch(server: Server) { + return async function fetchUsageStats(): Promise { + const internalRepo = server.newPlatform.setup.core.savedObjects.createInternalRepository(); + const uiSettingsClient = server.newPlatform.start.core.uiSettings.asScopedToClient( + new SavedObjectsClient(internalRepo) + ); + + const user = await uiSettingsClient.getUserProvided(); + const modifiedEntries = Object.keys(user) + .filter((key: string) => key !== 'buildNum') + .reduce((obj: any, key: string) => { + obj[key] = user[key].userValue; + return obj; + }, {}); + + return modifiedEntries; + }; +} + +export function registerManagementUsageCollector( + usageCollection: UsageCollectionSetup, + server: any +) { + const collector = usageCollection.makeUsageCollector({ + type: KIBANA_MANAGEMENT_STATS_TYPE, + isReady: () => true, + fetch: createCollectorFetch(server), + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts index 06a974f473498..b5b53b1daba55 100644 --- a/src/legacy/core_plugins/telemetry/server/plugin.ts +++ b/src/legacy/core_plugins/telemetry/server/plugin.ts @@ -27,6 +27,7 @@ import { registerTelemetryUsageCollector, registerLocalizationUsageCollector, registerTelemetryPluginUsageCollector, + registerManagementUsageCollector, } from './collectors'; export interface PluginsSetup { @@ -50,5 +51,6 @@ export class TelemetryPlugin { registerLocalizationUsageCollector(usageCollection, server); registerTelemetryUsageCollector(usageCollection, server); registerUiMetricUsageCollector(usageCollection, server); + registerManagementUsageCollector(usageCollection, server); } } diff --git a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js index 02b1f5fec9c19..2f8a2264530d5 100644 --- a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js @@ -29,16 +29,7 @@ export const createTestEntryTemplate = defaultUiSettings => bundle => ` * */ -// import global polyfills before everything else -import 'core-js/stable'; -import 'regenerator-runtime/runtime'; -import 'custom-event-polyfill'; -import 'whatwg-fetch'; -import 'abortcontroller-polyfill'; -import 'childnode-remove-polyfill'; import fetchMock from 'fetch-mock/es5/client'; -import Symbol_observable from 'symbol-observable'; - import { CoreSystem } from '__kibanaCore__'; // Fake uiCapabilities returned to Core in browser tests diff --git a/src/legacy/core_plugins/vis_type_timeseries/index.ts b/src/legacy/core_plugins/vis_type_timeseries/index.ts index 9ca14b28e19b2..a502bb174bc99 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/index.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/index.ts @@ -32,6 +32,20 @@ const metricsPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPlu styleSheetPaths: resolve(__dirname, 'public/index.scss'), hacks: [resolve(__dirname, 'public/legacy')], injectDefaultVars: server => ({}), + mappings: { + 'tsvb-validation-telemetry': { + properties: { + failedRequests: { + type: 'long', + }, + }, + }, + }, + savedObjectSchemas: { + 'tsvb-validation-telemetry': { + isNamespaceAgnostic: true, + }, + }, }, init: (server: Legacy.Server) => { const visTypeTimeSeriesPlugin = server.newPlatform.setup.plugins diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/__snapshots__/terms.test.js.snap b/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/__snapshots__/terms.test.js.snap index ffd4d08204a7e..654e7d9da4dca 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/__snapshots__/terms.test.js.snap +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/splits/__snapshots__/terms.test.js.snap @@ -87,9 +87,6 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js labelType="label" > @@ -112,9 +109,6 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js labelType="label" > diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts b/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts index 8740f84dab3b9..225d81b71b8e0 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts @@ -31,6 +31,7 @@ type Context = KibanaContext | null; interface Arguments { params: string; uiState: string; + savedObjectId: string | null; } type VisParams = Required; @@ -64,10 +65,16 @@ export const createMetricsFn = (): ExpressionFunction { +export const metricsRequestHandler = async ({ + uiState, + timeRange, + filters, + query, + visParams, + savedObjectId, +}) => { const config = getUISettings(); const timezone = timezoneProvider(config)(); const uiStateObj = uiState.get(visParams.type, {}); @@ -49,6 +56,7 @@ export const metricsRequestHandler = async ({ uiState, timeRange, filters, query filters, panels: [visParams], state: uiStateObj, + savedObjectId: savedObjectId || 'unsaved', }), }); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/init.ts b/src/legacy/core_plugins/vis_type_timeseries/server/init.ts index 7b42ae8098016..ae6eebc00fc1b 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/server/init.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/server/init.ts @@ -26,12 +26,17 @@ import { SearchStrategiesRegister } from './lib/search_strategies/search_strateg // @ts-ignore import { getVisData } from './lib/get_vis_data'; import { Framework } from '../../../../plugins/vis_type_timeseries/server'; +import { ValidationTelemetryServiceSetup } from '../../../../plugins/vis_type_timeseries/server'; -export const init = async (framework: Framework, __LEGACY: any) => { +export const init = async ( + framework: Framework, + __LEGACY: any, + validationTelemetry: ValidationTelemetryServiceSetup +) => { const { core } = framework; const router = core.http.createRouter(); - visDataRoutes(router, framework); + visDataRoutes(router, framework, validationTelemetry); // [LEGACY_TODO] fieldsRoutes(__LEGACY.server); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts new file mode 100644 index 0000000000000..3aca50b5b4710 --- /dev/null +++ b/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -0,0 +1,247 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Joi from 'joi'; +const stringOptionalNullable = Joi.string() + .allow('', null) + .optional(); +const stringRequired = Joi.string() + .allow('') + .required(); +const arrayNullable = Joi.array().allow(null); +const numberIntegerOptional = Joi.number() + .integer() + .optional(); +const numberIntegerRequired = Joi.number() + .integer() + .required(); +const numberOptional = Joi.number().optional(); +const numberRequired = Joi.number().required(); +const queryObject = Joi.object({ + language: Joi.string().allow(''), + query: Joi.string().allow(''), +}); + +const annotationsItems = Joi.object({ + color: stringOptionalNullable, + fields: stringOptionalNullable, + hidden: Joi.boolean().optional(), + icon: stringOptionalNullable, + id: stringOptionalNullable, + ignore_global_filters: numberIntegerOptional, + ignore_panel_filters: numberIntegerOptional, + index_pattern: stringOptionalNullable, + query_string: queryObject.optional(), + template: stringOptionalNullable, + time_field: stringOptionalNullable, +}); + +const backgroundColorRulesItems = Joi.object({ + value: Joi.number() + .allow(null) + .optional(), + id: stringOptionalNullable, + background_color: stringOptionalNullable, + color: stringOptionalNullable, +}); + +const gaugeColorRulesItems = Joi.object({ + gauge: stringOptionalNullable, + id: stringOptionalNullable, + operator: stringOptionalNullable, + value: Joi.number(), +}); +const metricsItems = Joi.object({ + field: stringOptionalNullable, + id: stringRequired, + metric_agg: stringOptionalNullable, + numerator: stringOptionalNullable, + denominator: stringOptionalNullable, + sigma: stringOptionalNullable, + function: stringOptionalNullable, + script: stringOptionalNullable, + variables: Joi.array() + .items( + Joi.object({ + field: stringOptionalNullable, + id: stringRequired, + name: stringOptionalNullable, + }) + ) + .optional(), + type: stringRequired, + value: stringOptionalNullable, + values: Joi.array() + .items(Joi.string().allow('', null)) + .allow(null) + .optional(), +}); + +const splitFiltersItems = Joi.object({ + id: stringOptionalNullable, + color: stringOptionalNullable, + filter: Joi.object({ + language: Joi.string().allow(''), + query: Joi.string().allow(''), + }).optional(), + label: stringOptionalNullable, +}); + +const seriesItems = Joi.object({ + aggregate_by: stringOptionalNullable, + aggregate_function: stringOptionalNullable, + axis_position: stringRequired, + axis_max: stringOptionalNullable, + axis_min: stringOptionalNullable, + chart_type: stringRequired, + color: stringRequired, + color_rules: Joi.array() + .items( + Joi.object({ + value: numberOptional, + id: stringRequired, + text: stringOptionalNullable, + operator: stringOptionalNullable, + }) + ) + .optional(), + fill: numberOptional, + filter: Joi.object({ + query: stringRequired, + language: stringOptionalNullable, + }).optional(), + formatter: stringRequired, + hide_in_legend: numberIntegerOptional, + hidden: Joi.boolean().optional(), + id: stringRequired, + label: stringOptionalNullable, + line_width: numberOptional, + metrics: Joi.array().items(metricsItems), + offset_time: stringOptionalNullable, + override_index_pattern: numberOptional, + point_size: numberRequired, + separate_axis: numberIntegerOptional, + seperate_axis: numberIntegerOptional, + series_index_pattern: stringOptionalNullable, + series_time_field: stringOptionalNullable, + series_interval: stringOptionalNullable, + series_drop_last_bucket: numberIntegerOptional, + split_color_mode: stringOptionalNullable, + split_filters: Joi.array() + .items(splitFiltersItems) + .optional(), + split_mode: stringRequired, + stacked: stringRequired, + steps: numberIntegerOptional, + terms_field: stringOptionalNullable, + terms_order_by: stringOptionalNullable, + terms_size: stringOptionalNullable, + terms_direction: stringOptionalNullable, + terms_include: stringOptionalNullable, + terms_exclude: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + trend_arrows: numberOptional, + type: stringOptionalNullable, + value_template: stringOptionalNullable, + var_name: stringOptionalNullable, +}); + +export const visPayloadSchema = Joi.object({ + filters: arrayNullable, + panels: Joi.array().items( + Joi.object({ + annotations: Joi.array() + .items(annotationsItems) + .optional(), + axis_formatter: stringRequired, + axis_position: stringRequired, + axis_scale: stringRequired, + axis_min: stringOptionalNullable, + axis_max: stringOptionalNullable, + bar_color_rules: arrayNullable.optional(), + background_color: stringOptionalNullable, + background_color_rules: Joi.array() + .items(backgroundColorRulesItems) + .optional(), + default_index_pattern: stringOptionalNullable, + default_timefield: stringOptionalNullable, + drilldown_url: stringOptionalNullable, + drop_last_bucket: numberIntegerOptional, + filter: Joi.alternatives( + stringOptionalNullable, + Joi.object({ + language: stringOptionalNullable, + query: stringOptionalNullable, + }) + ), + gauge_color_rules: Joi.array() + .items(gaugeColorRulesItems) + .optional(), + gauge_width: [stringOptionalNullable, numberOptional], + gauge_inner_color: stringOptionalNullable, + gauge_inner_width: Joi.alternatives(stringOptionalNullable, numberIntegerOptional), + gauge_style: stringOptionalNullable, + gauge_max: stringOptionalNullable, + id: stringRequired, + ignore_global_filters: numberOptional, + ignore_global_filter: numberOptional, + index_pattern: stringRequired, + interval: stringRequired, + isModelInvalid: Joi.boolean().optional(), + legend_position: stringOptionalNullable, + markdown: stringOptionalNullable, + markdown_scrollbars: numberIntegerOptional, + markdown_openLinksInNewTab: numberIntegerOptional, + markdown_vertical_align: stringOptionalNullable, + markdown_less: stringOptionalNullable, + markdown_css: stringOptionalNullable, + pivot_id: stringOptionalNullable, + pivot_label: stringOptionalNullable, + pivot_type: stringOptionalNullable, + pivot_rows: stringOptionalNullable, + series: Joi.array() + .items(seriesItems) + .required(), + show_grid: numberIntegerRequired, + show_legend: numberIntegerRequired, + time_field: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + type: stringRequired, + }) + ), + // general + query: Joi.array() + .items(queryObject) + .allow(null) + .required(), + state: Joi.object({ + sort: Joi.object({ + column: stringRequired, + order: Joi.string() + .valid(['asc', 'desc']) + .required(), + }).optional(), + }).required(), + savedObjectId: Joi.string().optional(), + timerange: Joi.object({ + timezone: stringRequired, + min: stringRequired, + max: stringRequired, + }).required(), +}); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js b/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts similarity index 59% rename from src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js rename to src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts index d2ded81309ffa..32e87f5a3f666 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts @@ -17,12 +17,22 @@ * under the License. */ +import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData } from '../lib/get_vis_data'; +import { visPayloadSchema } from './post_vis_schema'; +import { + Framework, + ValidationTelemetryServiceSetup, +} from '../../../../../plugins/vis_type_timeseries/server'; const escapeHatch = schema.object({}, { allowUnknowns: true }); -export const visDataRoutes = (router, framework) => { +export const visDataRoutes = ( + router: IRouter, + framework: Framework, + { logFailedValidation }: ValidationTelemetryServiceSetup +) => { router.post( { path: '/api/metrics/vis/data', @@ -31,6 +41,16 @@ export const visDataRoutes = (router, framework) => { }, }, async (requestContext, request, response) => { + const { error: validationError } = visPayloadSchema.validate(request.body); + if (validationError) { + logFailedValidation(); + const savedObjectId = + (typeof request.body === 'object' && (request.body as any).savedObjectId) || + 'unavailable'; + framework.logger.warn( + `Request validation error: ${validationError.message} (saved object id: ${savedObjectId}). This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` + ); + } try { const results = await getVisData(requestContext, request.body, framework); return response.ok({ body: results }); diff --git a/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_image_512.png b/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_image_512.png index 44cd0d320931f..cc28886794f03 100644 Binary files a/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_image_512.png and b/src/legacy/core_plugins/vis_type_vega/public/__tests__/vega_image_512.png differ diff --git a/src/legacy/core_plugins/vis_type_vega/public/data_model/__tests__/vega_parser.js b/src/legacy/core_plugins/vis_type_vega/public/data_model/__tests__/vega_parser.js index de2d913221451..c442f8f17884a 100644 --- a/src/legacy/core_plugins/vis_type_vega/public/data_model/__tests__/vega_parser.js +++ b/src/legacy/core_plugins/vis_type_vega/public/data_model/__tests__/vega_parser.js @@ -53,7 +53,7 @@ describe(`VegaParser._setDefaultColors`, () => { test({}, true, { config: { range: { category: { scheme: 'elastic' } }, - mark: { color: '#00B3A4' }, + mark: { color: '#5BBAA0' }, }, }) ); @@ -63,15 +63,15 @@ describe(`VegaParser._setDefaultColors`, () => { test({}, false, { config: { range: { category: { scheme: 'elastic' } }, - arc: { fill: '#00B3A4' }, - area: { fill: '#00B3A4' }, - line: { stroke: '#00B3A4' }, - path: { stroke: '#00B3A4' }, - rect: { fill: '#00B3A4' }, - rule: { stroke: '#00B3A4' }, - shape: { stroke: '#00B3A4' }, - symbol: { fill: '#00B3A4' }, - trail: { fill: '#00B3A4' }, + arc: { fill: '#5BBAA0' }, + area: { fill: '#5BBAA0' }, + line: { stroke: '#5BBAA0' }, + path: { stroke: '#5BBAA0' }, + rect: { fill: '#5BBAA0' }, + rule: { stroke: '#5BBAA0' }, + shape: { stroke: '#5BBAA0' }, + symbol: { fill: '#5BBAA0' }, + trail: { fill: '#5BBAA0' }, }, }) ); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index cc2ab133941db..ab1664d612b35 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -59,7 +59,12 @@ export interface Schemas { [key: string]: any[] | undefined; } -type buildVisFunction = (visState: VisState, schemas: Schemas, uiState: any) => string; +type buildVisFunction = ( + visState: VisState, + schemas: Schemas, + uiState: any, + meta?: { savedObjectId?: string } +) => string; type buildVisConfigFunction = (schemas: Schemas, visParams?: VisParams) => VisParams; interface BuildPipelineVisFunction { @@ -248,11 +253,13 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { input_control_vis: visState => { return `input_control_vis ${prepareJson('visConfig', visState.params)}`; }, - metrics: (visState, schemas, uiState = {}) => { + metrics: (visState, schemas, uiState = {}, meta) => { const paramsJson = prepareJson('params', visState.params); const uiStateJson = prepareJson('uiState', uiState); + const savedObjectIdParam = prepareString('savedObjectId', meta?.savedObjectId); - return `tsvb ${paramsJson} ${uiStateJson}`; + const params = [paramsJson, uiStateJson, savedObjectIdParam].filter(param => Boolean(param)); + return `tsvb ${params.join(' ')}`; }, timelion: visState => { const expression = prepareString('expression', visState.params.expression); @@ -488,6 +495,7 @@ export const buildPipeline = async ( params: { searchSource: ISearchSource; timeRange?: any; + savedObjectId?: string; } ) => { const { searchSource } = params; @@ -521,7 +529,9 @@ export const buildPipeline = async ( const schemas = getSchemas(vis, params.timeRange); if (buildPipelineVisFunction[vis.type.name]) { - pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState); + pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState, { + savedObjectId: params.savedObjectId, + }); } else if (vislibCharts.includes(vis.type.name)) { const visConfig = visState.params; visConfig.dimensions = await buildVislibDimensions(vis, params); diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/bytes/__snapshots__/bytes.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/bytes/__snapshots__/bytes.test.js.snap index 463c1bfb975f5..1f77660c9784c 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/bytes/__snapshots__/bytes.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/bytes/__snapshots__/bytes.test.js.snap @@ -44,10 +44,7 @@ exports[`BytesFormatEditor should render normally 1`] = ` labelType="label" > diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/color/__snapshots__/color.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/color/__snapshots__/color.test.js.snap index d7026df761d94..7e49e93e4cc4f 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/color/__snapshots__/color.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/color/__snapshots__/color.test.js.snap @@ -75,6 +75,7 @@ exports[`ColorFormatEditor should render multiple colors 1`] = ` } noItemsMessage="No items found" responsive={true} + tableLayout="fixed" /> diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/date_nanos/__snapshots__/date_nanos.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/date_nanos/__snapshots__/date_nanos.test.js.snap index 04d59640554fd..cb570144fcee3 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/date_nanos/__snapshots__/date_nanos.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/date_nanos/__snapshots__/date_nanos.test.js.snap @@ -44,11 +44,8 @@ exports[`DateFormatEditor should render normally 1`] = ` labelType="label" > diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/duration/__snapshots__/duration.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/duration/__snapshots__/duration.test.js.snap index 9722a01986434..ef11d70926ad7 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/duration/__snapshots__/duration.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/duration/__snapshots__/duration.test.js.snap @@ -20,11 +20,7 @@ exports[`DurationFormatEditor should render human readable output normally 1`] = labelType="label" > diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/percent/__snapshots__/percent.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/percent/__snapshots__/percent.test.js.snap index fea665a918f06..30d1de270522e 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/percent/__snapshots__/percent.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/percent/__snapshots__/percent.test.js.snap @@ -44,10 +44,7 @@ exports[`PercentFormatEditor should render normally 1`] = ` labelType="label" > diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.js.snap index 2891b99bba30c..2bfb0bbd15013 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/static_lookup/__snapshots__/static_lookup.test.js.snap @@ -59,6 +59,7 @@ exports[`StaticLookupFormatEditor should render multiple lookup entries and unkn "maxWidth": "400px", } } + tableLayout="fixed" />
diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url.test.js.snap index 4b246fecb8146..c727f54874db4 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url.test.js.snap @@ -26,11 +26,7 @@ exports[`UrlFormatEditor should render label template help 1`] = ` labelType="label" > @@ -116,10 +109,7 @@ exports[`UrlFormatEditor should render label template help 1`] = ` labelType="label" > @@ -157,11 +147,7 @@ exports[`UrlFormatEditor should render normally 1`] = ` labelType="label" > @@ -247,10 +230,7 @@ exports[`UrlFormatEditor should render normally 1`] = ` labelType="label" > @@ -288,11 +268,7 @@ exports[`UrlFormatEditor should render url template help 1`] = ` labelType="label" > @@ -378,10 +351,7 @@ exports[`UrlFormatEditor should render url template help 1`] = ` labelType="label" > @@ -419,11 +389,7 @@ exports[`UrlFormatEditor should render width and height fields if image 1`] = ` labelType="label" > @@ -510,10 +473,7 @@ exports[`UrlFormatEditor should render width and height fields if image 1`] = ` labelType="label" > diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url_template_flyout.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url_template_flyout.test.js.snap index 39189caeedb32..849e307f7b527 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url_template_flyout.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/editors/url/__snapshots__/url_template_flyout.test.js.snap @@ -110,6 +110,7 @@ exports[`UrlTemplateFlyout should render normally 1`] = ` } noItemsMessage="No items found" responsive={true} + tableLayout="fixed" />
diff --git a/src/legacy/ui/public/field_editor/components/field_format_editor/samples/__snapshots__/samples.test.js.snap b/src/legacy/ui/public/field_editor/components/field_format_editor/samples/__snapshots__/samples.test.js.snap index 25cbbb7c8684b..73a7c1141c601 100644 --- a/src/legacy/ui/public/field_editor/components/field_format_editor/samples/__snapshots__/samples.test.js.snap +++ b/src/legacy/ui/public/field_editor/components/field_format_editor/samples/__snapshots__/samples.test.js.snap @@ -54,6 +54,7 @@ exports[`FormatEditorSamples should render normally 1`] = ` } noItemsMessage="No items found" responsive={true} + tableLayout="fixed" /> `; diff --git a/src/legacy/ui/public/field_editor/field_editor.test.js b/src/legacy/ui/public/field_editor/field_editor.test.js index f811ad9162728..cf61b6140f42c 100644 --- a/src/legacy/ui/public/field_editor/field_editor.test.js +++ b/src/legacy/ui/public/field_editor/field_editor.test.js @@ -50,9 +50,7 @@ jest.mock('@elastic/eui', () => ({ EuiText: 'eui-text', EuiTextArea: 'eui-textArea', htmlIdGenerator: () => 42, - palettes: { - euiPaletteColorBlind: { colors: ['red'] }, - }, + euiPaletteColorBlind: () => ['red'], })); jest.mock('ui/scripting_languages', () => ({ diff --git a/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/metric_agg.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/metric_agg.test.tsx.snap index a176295260c44..b51c25952580a 100644 --- a/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/metric_agg.test.tsx.snap +++ b/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/metric_agg.test.tsx.snap @@ -16,9 +16,7 @@ exports[`MetricAggParamEditor should be rendered with default set of props 1`] = compressed={true} data-test-subj="visEditorSubAggMetric1" fullWidth={true} - hasNoInitialSelection={false} isInvalid={false} - isLoading={false} onChange={[Function]} options={ Array [ diff --git a/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/top_aggregate.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/top_aggregate.test.tsx.snap index 74c952dbf059f..b3a2c058de976 100644 --- a/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/top_aggregate.test.tsx.snap +++ b/src/legacy/ui/public/vis/editors/default/controls/__snapshots__/top_aggregate.test.tsx.snap @@ -31,9 +31,7 @@ exports[`TopAggregateParamEditor should init with the default set of props 1`] = data-test-subj="visDefaultEditorAggregateWith" disabled={false} fullWidth={true} - hasNoInitialSelection={false} isInvalid={false} - isLoading={false} onBlur={[MockFunction]} onChange={[Function]} options={ diff --git a/src/legacy/ui/public/vislib/visualizations/point_series/_point_series.js b/src/legacy/ui/public/vislib/visualizations/point_series/_point_series.js index b9f50b6d941e6..b882048a6b269 100644 --- a/src/legacy/ui/public/vislib/visualizations/point_series/_point_series.js +++ b/src/legacy/ui/public/vislib/visualizations/point_series/_point_series.js @@ -18,14 +18,14 @@ */ import _ from 'lodash'; -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; const thresholdLineDefaults = { show: false, value: 10, width: 1, style: 'full', - color: palettes.euiPaletteColorBlind.colors[9], + color: euiPaletteColorBlind()[9], }; export class PointSeries { diff --git a/src/legacy/ui/ui_bundles/app_entry_template.js b/src/legacy/ui/ui_bundles/app_entry_template.js index f0ca97f473324..a1c3a153a196c 100644 --- a/src/legacy/ui/ui_bundles/app_entry_template.js +++ b/src/legacy/ui/ui_bundles/app_entry_template.js @@ -28,14 +28,6 @@ export const appEntryTemplate = bundle => ` * context: ${bundle.getContext()} */ -// import global polyfills -import Symbol_observable from 'symbol-observable'; -import 'core-js/stable'; -import 'regenerator-runtime/runtime'; -import 'custom-event-polyfill'; -import 'whatwg-fetch'; -import 'abortcontroller-polyfill'; -import 'childnode-remove-polyfill'; ${apmImport()} import { i18n } from '@kbn/i18n'; import { CoreSystem } from '__kibanaCore__' diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs index 85b6de26b9516..72dd97ff58642 100644 --- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs +++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs @@ -13,7 +13,10 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { window.onload = function () { var files = [ - '{{dllBundlePath}}/vendors.bundle.dll.js', + '{{dllBundlePath}}/vendors_runtime.bundle.dll.js', + {{#each dllJsChunks}} + '{{this}}', + {{/each}} '{{regularBundlePath}}/kbn-ui-shared-deps/{{sharedDepsFilename}}', '{{regularBundlePath}}/commons.bundle.js', '{{regularBundlePath}}/{{appId}}.bundle.js' diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index a935270d23fce..4158af19bd858 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -26,6 +26,7 @@ import { AppBootstrap } from './bootstrap'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; import { getApmConfig } from '../apm'; +import { DllCompiler } from '../../../optimize/dynamic_dll_plugin'; /** * @typedef {import('../../server/kbn_server').default} KbnServer @@ -103,8 +104,14 @@ export function uiRenderMixin(kbnServer, server, config) { const basePath = config.get('server.basePath'); const regularBundlePath = `${basePath}/bundles`; const dllBundlePath = `${basePath}/built_assets/dlls`; + const dllStyleChunks = DllCompiler.getRawDllConfig().chunks.map( + chunk => `${dllBundlePath}/vendors${chunk}.style.dll.css` + ); + const dllJsChunks = DllCompiler.getRawDllConfig().chunks.map( + chunk => `${dllBundlePath}/vendors${chunk}.bundle.dll.js` + ); const styleSheetPaths = [ - `${dllBundlePath}/vendors.style.dll.css`, + ...dllStyleChunks, ...(darkMode ? [ `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`, @@ -132,6 +139,7 @@ export function uiRenderMixin(kbnServer, server, config) { appId: isCore ? 'core' : app.getId(), regularBundlePath, dllBundlePath, + dllJsChunks, styleSheetPaths, sharedDepsFilename: UiSharedDeps.distFilename, }, diff --git a/src/optimize/dynamic_dll_plugin/dll_compiler.js b/src/optimize/dynamic_dll_plugin/dll_compiler.js index 7d558367c032d..9889c1f71c3bf 100644 --- a/src/optimize/dynamic_dll_plugin/dll_compiler.js +++ b/src/optimize/dynamic_dll_plugin/dll_compiler.js @@ -23,6 +23,11 @@ import { notInNodeModules, inDllPluginPublic, } from './dll_allowed_modules'; +import { + dllEntryFileContentArrayToString, + dllEntryFileContentStringToArray, + dllMergeAllEntryFilesContent, +} from './dll_entry_template'; import { fromRoot } from '../../core/server/utils'; import { PUBLIC_PATH_PLACEHOLDER } from '../public_path_placeholder'; import fs from 'fs'; @@ -30,6 +35,8 @@ import webpack from 'webpack'; import { promisify } from 'util'; import path from 'path'; import del from 'del'; +import { chunk } from 'lodash'; +import seedrandom from 'seedrandom'; const readFileAsync = promisify(fs.readFile); const mkdirAsync = promisify(fs.mkdir); @@ -37,11 +44,17 @@ const accessAsync = promisify(fs.access); const writeFileAsync = promisify(fs.writeFile); export class DllCompiler { - static getRawDllConfig(uiBundles = {}, babelLoaderCacheDir = '', threadLoaderPoolConfig = {}) { + static getRawDllConfig( + uiBundles = {}, + babelLoaderCacheDir = '', + threadLoaderPoolConfig = {}, + chunks = Array.from(Array(4).keys()).map(chunkN => `_${chunkN}`) + ) { return { uiBundles, babelLoaderCacheDir, threadLoaderPoolConfig, + chunks, context: fromRoot('.'), entryName: 'vendors', dllName: '[name]', @@ -66,13 +79,49 @@ export class DllCompiler { } async init() { - await this.ensureEntryFileExists(); - await this.ensureManifestFileExists(); + await this.ensureEntryFilesExists(); + await this.ensureManifestFilesExists(); await this.ensureOutputPathExists(); } - async upsertEntryFile(content) { - await this.upsertFile(this.getEntryPath(), content); + seededShuffle(array) { + // Implementation based on https://github.com/TimothyGu/knuth-shuffle-seeded/blob/gh-pages/index.js#L46 + let currentIndex; + let temporaryValue; + let randomIndex; + const rand = seedrandom('predictable', { global: false }); + + if (array.constructor !== Array) throw new Error('Input is not an array'); + currentIndex = array.length; + + // While there remain elements to shuffle... + while (0 !== currentIndex) { + // Pick a remaining element... + randomIndex = Math.floor(rand() * currentIndex--); + + // And swap it with the current element. + temporaryValue = array[currentIndex]; + array[currentIndex] = array[randomIndex]; + array[randomIndex] = temporaryValue; + } + + return array; + } + + async upsertEntryFiles(content) { + const arrayContent = this.seededShuffle(dllEntryFileContentStringToArray(content)); + const chunks = chunk( + arrayContent, + Math.ceil(arrayContent.length / this.rawDllConfig.chunks.length) + ); + const entryPaths = this.getEntryPaths(); + + await Promise.all( + entryPaths.map( + async (entryPath, idx) => + await this.upsertFile(entryPath, dllEntryFileContentArrayToString(chunks[idx])) + ) + ); } async upsertFile(filePath, content = '') { @@ -80,38 +129,57 @@ export class DllCompiler { await writeFileAsync(filePath, content, 'utf8'); } - getDllPath() { - return this.resolvePath(`${this.rawDllConfig.entryName}${this.rawDllConfig.dllExt}`); + getDllPaths() { + return this.rawDllConfig.chunks.map(chunk => + this.resolvePath(`${this.rawDllConfig.entryName}${chunk}${this.rawDllConfig.dllExt}`) + ); } - getEntryPath() { - return this.resolvePath(`${this.rawDllConfig.entryName}${this.rawDllConfig.entryExt}`); + getEntryPaths() { + return this.rawDllConfig.chunks.map(chunk => + this.resolvePath(`${this.rawDllConfig.entryName}${chunk}${this.rawDllConfig.entryExt}`) + ); } - getManifestPath() { - return this.resolvePath(`${this.rawDllConfig.entryName}${this.rawDllConfig.manifestExt}`); + getManifestPaths() { + return this.rawDllConfig.chunks.map(chunk => + this.resolvePath(`${this.rawDllConfig.entryName}${chunk}${this.rawDllConfig.manifestExt}`) + ); } - getStylePath() { - return this.resolvePath(`${this.rawDllConfig.entryName}${this.rawDllConfig.styleExt}`); + getStylePaths() { + return this.rawDllConfig.chunks.map(chunk => + this.resolvePath(`${this.rawDllConfig.entryName}${chunk}${this.rawDllConfig.styleExt}`) + ); } - async ensureEntryFileExists() { - await this.ensureFileExists(this.getEntryPath()); + async ensureEntryFilesExists() { + const entryPaths = this.getEntryPaths(); + + await Promise.all(entryPaths.map(async entryPath => await this.ensureFileExists(entryPath))); } - async ensureManifestFileExists() { - await this.ensureFileExists( - this.getManifestPath(), - JSON.stringify({ - name: this.rawDllConfig.entryName, - content: {}, - }) + async ensureManifestFilesExists() { + const manifestPaths = this.getManifestPaths(); + + await Promise.all( + manifestPaths.map( + async (manifestPath, idx) => + await this.ensureFileExists( + manifestPath, + JSON.stringify({ + name: `${this.rawDllConfig.entryName}${this.rawDllConfig.chunks[idx]}`, + content: {}, + }) + ) + ) ); } async ensureStyleFileExists() { - await this.ensureFileExists(this.getStylePath()); + const stylePaths = this.getStylePaths(); + + await Promise.all(stylePaths.map(async stylePath => await this.ensureFileExists(stylePath))); } async ensureFileExists(filePath, content) { @@ -137,8 +205,10 @@ export class DllCompiler { await this.ensurePathExists(this.rawDllConfig.outputPath); } - dllExistsSync() { - return this.existsSync(this.getDllPath()); + dllsExistsSync() { + const dllPaths = this.getDllPaths(); + + return dllPaths.every(dllPath => this.existsSync(dllPath)); } existsSync(filePath) { @@ -149,8 +219,16 @@ export class DllCompiler { return path.resolve(this.rawDllConfig.outputPath, ...arguments); } - async readEntryFile() { - return await this.readFile(this.getEntryPath()); + async readEntryFiles() { + const entryPaths = this.getEntryPaths(); + + const entryFilesContent = await Promise.all( + entryPaths.map(async entryPath => await this.readFile(entryPath)) + ); + + // merge all the module contents from entry files again into + // sorted single one + return dllMergeAllEntryFilesContent(entryFilesContent); } async readFile(filePath, content) { @@ -160,7 +238,7 @@ export class DllCompiler { async run(dllEntries) { const dllConfig = this.dllConfigGenerator(this.rawDllConfig); - await this.upsertEntryFile(dllEntries); + await this.upsertEntryFiles(dllEntries); try { this.logWithMetadata( @@ -234,7 +312,7 @@ export class DllCompiler { // ignore if this module represents the // dll entry file - if (module.resource === this.getEntryPath()) { + if (this.getEntryPaths().includes(module.resource)) { return; } @@ -259,7 +337,6 @@ export class DllCompiler { // node_module or no? if (notInNodeModules(reason.module.resource)) { notAllowedModules.push(module.resource); - return; } }); } diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js index ecf5def5aa6ca..c7ab2fe30dd14 100644 --- a/src/optimize/dynamic_dll_plugin/dll_config_model.js +++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js @@ -140,6 +140,13 @@ function generateDLL(config) { filename: dllStyleFilename, }), ], + // Single runtime for the dll bundles which assures that common transient dependencies won't be evaluated twice. + // The module cache will be shared, even when module code may be duplicated across chunks. + optimization: { + runtimeChunk: { + name: 'vendors_runtime', + }, + }, performance: { // NOTE: we are disabling this as those hints // are more tailored for the final bundles result @@ -158,6 +165,7 @@ function extendRawConfig(rawConfig) { const dllNoParseRules = rawConfig.uiBundles.getWebpackNoParseRules(); const dllDevMode = rawConfig.uiBundles.isDevMode(); const dllContext = rawConfig.context; + const dllChunks = rawConfig.chunks; const dllEntry = {}; const dllEntryName = rawConfig.entryName; const dllBundleName = rawConfig.dllName; @@ -176,7 +184,12 @@ function extendRawConfig(rawConfig) { const threadLoaderPoolConfig = rawConfig.threadLoaderPoolConfig; // Create webpack entry object key with the provided dllEntryName - dllEntry[dllEntryName] = [`${dllOutputPath}/${dllEntryName}${dllEntryExt}`]; + dllChunks.reduce((dllEntryObj, chunk) => { + dllEntryObj[`${dllEntryName}${chunk}`] = [ + `${dllOutputPath}/${dllEntryName}${chunk}${dllEntryExt}`, + ]; + return dllEntryObj; + }, dllEntry); // Export dll config map return { diff --git a/src/optimize/dynamic_dll_plugin/dll_entry_template.js b/src/optimize/dynamic_dll_plugin/dll_entry_template.js index 584bf0c9e3d35..0c286896d0b71 100644 --- a/src/optimize/dynamic_dll_plugin/dll_entry_template.js +++ b/src/optimize/dynamic_dll_plugin/dll_entry_template.js @@ -23,3 +23,19 @@ export function dllEntryTemplate(requirePaths = []) { .sort() .join('\n'); } + +export function dllEntryFileContentStringToArray(content = '') { + return content.split('\n'); +} + +export function dllEntryFileContentArrayToString(content = []) { + return content.join('\n'); +} + +export function dllMergeAllEntryFilesContent(content = []) { + return content + .join('\n') + .split('\n') + .sort() + .join('\n'); +} diff --git a/src/optimize/dynamic_dll_plugin/dynamic_dll_plugin.js b/src/optimize/dynamic_dll_plugin/dynamic_dll_plugin.js index cb941d2ba5683..484c7dfbfd595 100644 --- a/src/optimize/dynamic_dll_plugin/dynamic_dll_plugin.js +++ b/src/optimize/dynamic_dll_plugin/dynamic_dll_plugin.js @@ -44,7 +44,7 @@ export class DynamicDllPlugin { async init() { await this.dllCompiler.init(); - this.entryPaths = await this.dllCompiler.readEntryFile(); + this.entryPaths = await this.dllCompiler.readEntryFiles(); } apply(compiler) { @@ -70,12 +70,14 @@ export class DynamicDllPlugin { bindDllReferencePlugin(compiler) { const rawDllConfig = this.dllCompiler.rawDllConfig; const dllContext = rawDllConfig.context; - const dllManifestPath = this.dllCompiler.getManifestPath(); + const dllManifestPaths = this.dllCompiler.getManifestPaths(); - new webpack.DllReferencePlugin({ - context: dllContext, - manifest: dllManifestPath, - }).apply(compiler); + dllManifestPaths.forEach(dllChunkManifestPath => { + new webpack.DllReferencePlugin({ + context: dllContext, + manifest: dllChunkManifestPath, + }).apply(compiler); + }); } registerInitBasicHooks(compiler) { @@ -192,7 +194,7 @@ export class DynamicDllPlugin { // then will be set to false compilation.needsDLLCompilation = this.afterCompilationEntryPaths !== this.entryPaths || - !this.dllCompiler.dllExistsSync() || + !this.dllCompiler.dllsExistsSync() || (this.isToForceDLLCreation() && this.performedCompilations === 0); this.entryPaths = this.afterCompilationEntryPaths; @@ -337,7 +339,9 @@ export class DynamicDllPlugin { // We need to purge the cache into the inputFileSystem // for every single built in previous compilation // that we rely in next ones. - mainCompiler.inputFileSystem.purge(this.dllCompiler.getManifestPath()); + this.dllCompiler + .getManifestPaths() + .forEach(chunkDllManifestPath => mainCompiler.inputFileSystem.purge(chunkDllManifestPath)); this.performedCompilations++; diff --git a/src/optimize/watch/watch_cache.ts b/src/optimize/watch/watch_cache.ts index 15957210b3d43..b6784c1734a17 100644 --- a/src/optimize/watch/watch_cache.ts +++ b/src/optimize/watch/watch_cache.ts @@ -170,22 +170,20 @@ export class WatchCache { * very large folders (with 84K+ files) cause a stack overflow. */ async function recursiveDelete(directory: string) { - const entries = await readdirAsync(directory, { withFileTypes: true }); - await Promise.all( - entries.map(entry => { - const absolutePath = path.join(directory, entry.name); - const result = entry.isDirectory() - ? recursiveDelete(absolutePath) - : unlinkAsync(absolutePath); - - // Ignore errors, if the file or directory doesn't exist. - return result.catch(e => { - if (e.code !== 'ENOENT') { - throw e; - } - }); - }) - ); - - return rmdirAsync(directory); + try { + const entries = await readdirAsync(directory, { withFileTypes: true }); + + await Promise.all( + entries.map(entry => { + const absolutePath = path.join(directory, entry.name); + return entry.isDirectory() ? recursiveDelete(absolutePath) : unlinkAsync(absolutePath); + }) + ); + + return rmdirAsync(directory); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } } diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap index 7ab7d7653eb5e..4ec29ca409b80 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap @@ -170,6 +170,9 @@ exports[`LanguageSwitcher should toggle off if language is lucene 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -460,6 +463,9 @@ exports[`LanguageSwitcher should toggle on if language is kuery 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap index 6f5f9b3956187..4c8edd85eb559 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap @@ -276,6 +276,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -896,6 +899,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -1070,11 +1076,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA aria-label="Start typing to search and filter the test page" autoComplete="off" autoFocus={false} - compressed={false} data-test-subj="queryInput" fullWidth={true} inputRef={[Function]} - isLoading={false} onChange={[Function]} onClick={[Function]} onKeyDown={[Function]} @@ -1092,9 +1096,7 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA onSelectLanguage={[Function]} /> } - compressed={false} fullWidth={true} - isLoading={false} >
- +
} - compressed={false} fullWidth={true} - isLoading={false} >
- +
} - compressed={false} fullWidth={true} - isLoading={false} >
- +
; intl: InjectedIntl; isLoading?: boolean; - prepend?: React.ReactNode; + prepend?: React.ComponentProps['prepend']; showQueryInput?: boolean; showDatePicker?: boolean; dateRangeFrom?: string; diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 16b22a164f2f0..960a843f98ab9 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -58,7 +58,7 @@ interface Props { query: Query; disableAutoFocus?: boolean; screenTitle?: string; - prepend?: React.ReactNode; + prepend?: React.ComponentProps['prepend']; persistedLog?: PersistedLog; bubbleSubmitEvent?: boolean; placeholder?: string; diff --git a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap index 5abce10c5be61..fb56bf0e4255e 100644 --- a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap +++ b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap @@ -11,7 +11,7 @@ exports[`FieldIcon renders a blackwhite icon for a string 1`] = ` exports[`FieldIcon renders a colored icon for a number 1`] = ` @@ -20,7 +20,7 @@ exports[`FieldIcon renders a colored icon for a number 1`] = ` exports[`FieldIcon renders an icon for an unknown type 1`] = ` @@ -30,7 +30,7 @@ exports[`FieldIcon renders with className if provided 1`] = ` diff --git a/src/plugins/kibana_react/public/field_icon/field_icon.tsx b/src/plugins/kibana_react/public/field_icon/field_icon.tsx index f9bdf3a25adaa..0c5d2b0c24831 100644 --- a/src/plugins/kibana_react/public/field_icon/field_icon.tsx +++ b/src/plugins/kibana_react/public/field_icon/field_icon.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { palettes, EuiIcon } from '@elastic/eui'; +import { euiPaletteColorBlind, EuiIcon } from '@elastic/eui'; import { IconSize } from '@elastic/eui/src/components/icon/icon'; interface IconMapEntry { @@ -43,7 +43,7 @@ interface FieldIconProps { className?: string; } -const { colors } = palettes.euiPaletteColorBlind; +const colors = euiPaletteColorBlind(); // defaultIcon => a unknown datatype const defaultIcon = { icon: 'questionInCircle', color: colors[0] }; diff --git a/src/plugins/kibana_react/public/saved_objects/__snapshots__/saved_object_save_modal.test.tsx.snap b/src/plugins/kibana_react/public/saved_objects/__snapshots__/saved_object_save_modal.test.tsx.snap index 978705a3ad096..18f84f41d5d99 100644 --- a/src/plugins/kibana_react/public/saved_objects/__snapshots__/saved_object_save_modal.test.tsx.snap +++ b/src/plugins/kibana_react/public/saved_objects/__snapshots__/saved_object_save_modal.test.tsx.snap @@ -43,11 +43,9 @@ exports[`SavedObjectSaveModal should render matching snapshot 1`] = ` > diff --git a/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx b/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx index bd2beaf77a305..1522c6b42824c 100644 --- a/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx +++ b/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx @@ -346,6 +346,9 @@ class SavedObjectFinderUi extends React.Component< placeholder={i18n.translate('kibana-react.savedObjects.finder.searchPlaceholder', { defaultMessage: 'Search…', })} + aria-label={i18n.translate('kibana-react.savedObjects.finder.searchPlaceholder', { + defaultMessage: 'Search…', + })} fullWidth value={this.state.query} onChange={e => { diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 2e7b22a14fb0e..4c2dac4f39134 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -67,6 +67,11 @@ export interface TableListViewProps { tableListTitle: string; toastNotifications: ToastsStart; uiSettings: IUiSettingsClient; + /** + * Id of the heading element describing the table. This id will be used as `aria-labelledby` of the wrapper element. + * If the table is not empty, this component renders its own h1 element using the same id. + */ + headingId?: string; } export interface TableListViewState { @@ -463,7 +468,7 @@ class TableListView extends React.Component -

{this.props.tableListTitle}

+

{this.props.tableListTitle}

@@ -498,7 +503,11 @@ class TableListView extends React.Component - {this.renderPageContent()} + + {this.renderPageContent()} + ); } diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts index 5c50e152ad46c..b905aeff41f1f 100644 --- a/src/plugins/kibana_utils/demos/demos.test.ts +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -38,7 +38,7 @@ describe('demos', () => { describe('state sync', () => { test('url sync demo works', async () => { expect(await urlSyncResult).toMatchInlineSnapshot( - `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` + `"http://localhost/#?_s=(todos:!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test)))"` ); }); }); diff --git a/src/plugins/kibana_utils/demos/state_containers/counter.ts b/src/plugins/kibana_utils/demos/state_containers/counter.ts index 643763cc4cee9..4ddf532c1506d 100644 --- a/src/plugins/kibana_utils/demos/state_containers/counter.ts +++ b/src/plugins/kibana_utils/demos/state_containers/counter.ts @@ -19,14 +19,24 @@ import { createStateContainer } from '../../public/state_containers'; -const container = createStateContainer(0, { - increment: (cnt: number) => (by: number) => cnt + by, - double: (cnt: number) => () => cnt * 2, -}); +interface State { + count: number; +} + +const container = createStateContainer( + { count: 0 }, + { + increment: (state: State) => (by: number) => ({ count: state.count + by }), + double: (state: State) => () => ({ count: state.count * 2 }), + }, + { + count: (state: State) => () => state.count, + } +); container.transitions.increment(5); container.transitions.double(); -console.log(container.get()); // eslint-disable-line +console.log(container.selectors.count()); // eslint-disable-line -export const result = container.get(); +export const result = container.selectors.count(); diff --git a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts index 6d0c960e2a5b2..e807783a56f31 100644 --- a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts +++ b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts @@ -25,15 +25,19 @@ export interface TodoItem { id: number; } -export type TodoState = TodoItem[]; +export interface TodoState { + todos: TodoItem[]; +} -export const defaultState: TodoState = [ - { - id: 0, - text: 'Learning state containers', - completed: false, - }, -]; +export const defaultState: TodoState = { + todos: [ + { + id: 0, + text: 'Learning state containers', + completed: false, + }, + ], +}; export interface TodoActions { add: PureTransition; @@ -44,17 +48,34 @@ export interface TodoActions { clearCompleted: PureTransition; } +export interface TodosSelectors { + todos: (state: TodoState) => () => TodoItem[]; + todo: (state: TodoState) => (id: number) => TodoItem | null; +} + export const pureTransitions: TodoActions = { - add: state => todo => [...state, todo], - edit: state => todo => state.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), - delete: state => id => state.filter(item => item.id !== id), - complete: state => id => - state.map(item => (item.id === id ? { ...item, completed: true } : item)), - completeAll: state => () => state.map(item => ({ ...item, completed: true })), - clearCompleted: state => () => state.filter(({ completed }) => !completed), + add: state => todo => ({ todos: [...state.todos, todo] }), + edit: state => todo => ({ + todos: state.todos.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), + }), + delete: state => id => ({ todos: state.todos.filter(item => item.id !== id) }), + complete: state => id => ({ + todos: state.todos.map(item => (item.id === id ? { ...item, completed: true } : item)), + }), + completeAll: state => () => ({ todos: state.todos.map(item => ({ ...item, completed: true })) }), + clearCompleted: state => () => ({ todos: state.todos.filter(({ completed }) => !completed) }), +}; + +export const pureSelectors: TodosSelectors = { + todos: state => () => state.todos, + todo: state => id => state.todos.find(todo => todo.id === id) ?? null, }; -const container = createStateContainer(defaultState, pureTransitions); +const container = createStateContainer( + defaultState, + pureTransitions, + pureSelectors +); container.transitions.add({ id: 1, @@ -64,6 +85,6 @@ container.transitions.add({ container.transitions.complete(0); container.transitions.complete(1); -console.log(container.get()); // eslint-disable-line +console.log(container.selectors.todos()); // eslint-disable-line -export const result = container.get(); +export const result = container.selectors.todos(); diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts index 657b64f55a776..2c426cae6733a 100644 --- a/src/plugins/kibana_utils/demos/state_sync/url.ts +++ b/src/plugins/kibana_utils/demos/state_sync/url.ts @@ -18,7 +18,7 @@ */ import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; -import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; +import { BaseState, BaseStateContainer, createStateContainer } from '../../public/state_containers'; import { createKbnUrlStateStorage, syncState, @@ -55,7 +55,7 @@ export const result = Promise.resolve() return window.location.href; }); -function withDefaultState( +function withDefaultState( // eslint-disable-next-line no-shadow stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow diff --git a/src/plugins/kibana_utils/docs/state_containers/README.md b/src/plugins/kibana_utils/docs/state_containers/README.md index 3b7a8b8bd4621..583f8f65ce6b6 100644 --- a/src/plugins/kibana_utils/docs/state_containers/README.md +++ b/src/plugins/kibana_utils/docs/state_containers/README.md @@ -18,14 +18,21 @@ your services or apps. ```ts import { createStateContainer } from 'src/plugins/kibana_utils'; -const container = createStateContainer(0, { - increment: (cnt: number) => (by: number) => cnt + by, - double: (cnt: number) => () => cnt * 2, -}); +const container = createStateContainer( + { count: 0 }, + { + increment: (state: {count: number}) => (by: number) => ({ count: state.count + by }), + double: (state: {count: number}) => () => ({ count: state.count * 2 }), + }, + { + count: (state: {count: number}) => () => state.count, + } +); container.transitions.increment(5); container.transitions.double(); -console.log(container.get()); // 10 + +console.log(container.selectors.count()); // 10 ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/creation.md b/src/plugins/kibana_utils/docs/state_containers/creation.md index 66d28bbd8603f..f8ded75ed3f45 100644 --- a/src/plugins/kibana_utils/docs/state_containers/creation.md +++ b/src/plugins/kibana_utils/docs/state_containers/creation.md @@ -32,7 +32,7 @@ Create your a state container. ```ts import { createStateContainer } from 'src/plugins/kibana_utils'; -const container = createStateContainer(defaultState, {}); +const container = createStateContainer(defaultState); console.log(container.get()); ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/no_react.md b/src/plugins/kibana_utils/docs/state_containers/no_react.md index 7a15483d83b44..a72995f4f1eae 100644 --- a/src/plugins/kibana_utils/docs/state_containers/no_react.md +++ b/src/plugins/kibana_utils/docs/state_containers/no_react.md @@ -1,13 +1,13 @@ # Consuming state in non-React setting -To read the current `state` of the store use `.get()` method. +To read the current `state` of the store use `.get()` method or `getState()` alias method. ```ts -store.get(); +stateContainer.get(); ``` To listen for latest state changes use `.state$` observable. ```ts -store.state$.subscribe(state => { ... }); +stateContainer.state$.subscribe(state => { ... }); ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/react.md b/src/plugins/kibana_utils/docs/state_containers/react.md index 363fd9253d44f..1bab1af1d5f68 100644 --- a/src/plugins/kibana_utils/docs/state_containers/react.md +++ b/src/plugins/kibana_utils/docs/state_containers/react.md @@ -9,7 +9,7 @@ ```ts import { createStateContainer, createStateContainerReactHelpers } from 'src/plugins/kibana_utils'; -const container = createStateContainer({}, {}); +const container = createStateContainer({}); export const { Provider, Consumer, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts index 95f4c35f2ce01..d4877acaa5ca0 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts @@ -19,18 +19,9 @@ import { createStateContainer } from './create_state_container'; -const create = (state: S, transitions: T = {} as T) => { - const pureTransitions = { - set: () => (newState: S) => newState, - ...transitions, - }; - const store = createStateContainer(state, pureTransitions); - return { store, mutators: store.transitions }; -}; - -test('can create store', () => { - const { store } = create({}); - expect(store).toMatchObject({ +test('can create state container', () => { + const stateContainer = createStateContainer({}); + expect(stateContainer).toMatchObject({ getState: expect.any(Function), state$: expect.any(Object), transitions: expect.any(Object), @@ -45,9 +36,9 @@ test('can set default state', () => { const defaultState = { foo: 'bar', }; - const { store } = create(defaultState); - expect(store.get()).toEqual(defaultState); - expect(store.getState()).toEqual(defaultState); + const stateContainer = createStateContainer(defaultState); + expect(stateContainer.get()).toEqual(defaultState); + expect(stateContainer.getState()).toEqual(defaultState); }); test('can set state', () => { @@ -57,12 +48,12 @@ test('can set state', () => { const newState = { foo: 'baz', }; - const { store, mutators } = create(defaultState); + const stateContainer = createStateContainer(defaultState); - mutators.set(newState); + stateContainer.set(newState); - expect(store.get()).toEqual(newState); - expect(store.getState()).toEqual(newState); + expect(stateContainer.get()).toEqual(newState); + expect(stateContainer.getState()).toEqual(newState); }); test('does not shallow merge states', () => { @@ -72,22 +63,22 @@ test('does not shallow merge states', () => { const newState = { foo2: 'baz', }; - const { store, mutators } = create(defaultState); + const stateContainer = createStateContainer(defaultState); - mutators.set(newState as any); + stateContainer.set(newState as any); - expect(store.get()).toEqual(newState); - expect(store.getState()).toEqual(newState); + expect(stateContainer.get()).toEqual(newState); + expect(stateContainer.getState()).toEqual(newState); }); test('can subscribe and unsubscribe to state changes', () => { - const { store, mutators } = create({}); + const stateContainer = createStateContainer({}); const spy = jest.fn(); - const subscription = store.state$.subscribe(spy); - mutators.set({ a: 1 }); - mutators.set({ a: 2 }); + const subscription = stateContainer.state$.subscribe(spy); + stateContainer.set({ a: 1 }); + stateContainer.set({ a: 2 }); subscription.unsubscribe(); - mutators.set({ a: 3 }); + stateContainer.set({ a: 3 }); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0][0]).toEqual({ a: 1 }); @@ -95,16 +86,16 @@ test('can subscribe and unsubscribe to state changes', () => { }); test('multiple subscribers can subscribe', () => { - const { store, mutators } = create({}); + const stateContainer = createStateContainer({}); const spy1 = jest.fn(); const spy2 = jest.fn(); - const subscription1 = store.state$.subscribe(spy1); - const subscription2 = store.state$.subscribe(spy2); - mutators.set({ a: 1 }); + const subscription1 = stateContainer.state$.subscribe(spy1); + const subscription2 = stateContainer.state$.subscribe(spy2); + stateContainer.set({ a: 1 }); subscription1.unsubscribe(); - mutators.set({ a: 2 }); + stateContainer.set({ a: 2 }); subscription2.unsubscribe(); - mutators.set({ a: 3 }); + stateContainer.set({ a: 3 }); expect(spy1).toHaveBeenCalledTimes(1); expect(spy2).toHaveBeenCalledTimes(2); @@ -120,19 +111,19 @@ test('can create state container without transitions', () => { expect(stateContainer.get()).toEqual(state); }); -test('creates impure mutators from pure mutators', () => { - const { mutators } = create( +test('creates transitions', () => { + const stateContainer = createStateContainer( {}, { setFoo: () => (bar: any) => ({ foo: bar }), } ); - expect(typeof mutators.setFoo).toBe('function'); + expect(typeof stateContainer.transitions.setFoo).toBe('function'); }); -test('mutators can update state', () => { - const { store, mutators } = create( +test('transitions can update state', () => { + const stateContainer = createStateContainer( { value: 0, foo: 'bar', @@ -143,30 +134,30 @@ test('mutators can update state', () => { } ); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: 0, foo: 'bar', }); - mutators.add(11); - mutators.setFoo('baz'); + stateContainer.transitions.add(11); + stateContainer.transitions.setFoo('baz'); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: 11, foo: 'baz', }); - mutators.add(-20); - mutators.setFoo('bazooka'); + stateContainer.transitions.add(-20); + stateContainer.transitions.setFoo('bazooka'); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: -9, foo: 'bazooka', }); }); -test('mutators methods are not bound', () => { - const { store, mutators } = create( +test('transitions methods are not bound', () => { + const stateContainer = createStateContainer( { value: -3 }, { add: (state: { value: number }) => (increment: number) => ({ @@ -176,13 +167,13 @@ test('mutators methods are not bound', () => { } ); - expect(store.get()).toEqual({ value: -3 }); - mutators.add(4); - expect(store.get()).toEqual({ value: 1 }); + expect(stateContainer.get()).toEqual({ value: -3 }); + stateContainer.transitions.add(4); + expect(stateContainer.get()).toEqual({ value: 1 }); }); -test('created mutators are saved in store object', () => { - const { store, mutators } = create( +test('created transitions are saved in stateContainer object', () => { + const stateContainer = createStateContainer( { value: -3 }, { add: (state: { value: number }) => (increment: number) => ({ @@ -192,55 +183,57 @@ test('created mutators are saved in store object', () => { } ); - expect(typeof store.transitions.add).toBe('function'); - mutators.add(5); - expect(store.get()).toEqual({ value: 2 }); + expect(typeof stateContainer.transitions.add).toBe('function'); + stateContainer.transitions.add(5); + expect(stateContainer.get()).toEqual({ value: 2 }); }); -test('throws when state is modified inline - 1', () => { - const container = createStateContainer({ a: 'b' }, {}); +test('throws when state is modified inline', () => { + const container = createStateContainer({ a: 'b', array: [{ a: 'b' }] }); - let error: TypeError | null = null; - try { + expect(() => { (container.get().a as any) = 'c'; - } catch (err) { - error = err; - } + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); - expect(error).toBeInstanceOf(TypeError); -}); + expect(() => { + (container.getState().a as any) = 'c'; + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); -test('throws when state is modified inline - 2', () => { - const container = createStateContainer({ a: 'b' }, {}); + expect(() => { + (container.getState().array as any).push('c'); + }).toThrowErrorMatchingInlineSnapshot(`"Cannot add property 1, object is not extensible"`); - let error: TypeError | null = null; - try { - (container.getState().a as any) = 'c'; - } catch (err) { - error = err; - } + expect(() => { + (container.getState().array[0] as any).c = 'b'; + }).toThrowErrorMatchingInlineSnapshot(`"Cannot add property c, object is not extensible"`); - expect(error).toBeInstanceOf(TypeError); + expect(() => { + container.set(null as any); + expect(container.getState()).toBeNull(); + }).not.toThrow(); }); -test('throws when state is modified inline in subscription', done => { +test('throws when state is modified inline in subscription', () => { const container = createStateContainer({ a: 'b' }, { set: () => (newState: any) => newState }); container.subscribe(value => { - let error: TypeError | null = null; - try { + expect(() => { (value.a as any) = 'd'; - } catch (err) { - error = err; - } - expect(error).toBeInstanceOf(TypeError); - done(); + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); }); + container.transitions.set({ a: 'c' }); }); describe('selectors', () => { test('can specify no selectors, or can skip them', () => { + createStateContainer({}); createStateContainer({}, {}); createStateContainer({}, {}, {}); }); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts index b949a9daed0ae..d420aec30f068 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts @@ -20,34 +20,52 @@ import { BehaviorSubject } from 'rxjs'; import { skip } from 'rxjs/operators'; import { RecursiveReadonly } from '@kbn/utility-types'; +import deepFreeze from 'deep-freeze-strict'; import { PureTransitionsToTransitions, PureTransition, ReduxLikeStateContainer, PureSelectorsToSelectors, + BaseState, } from './types'; const $$observable = (typeof Symbol === 'function' && (Symbol as any).observable) || '@@observable'; +const $$setActionType = '@@SET'; const freeze: (value: T) => RecursiveReadonly = process.env.NODE_ENV !== 'production' ? (value: T): RecursiveReadonly => { - if (!value) return value as RecursiveReadonly; - if (value instanceof Array) return value as RecursiveReadonly; - if (typeof value === 'object') return Object.freeze({ ...value }) as RecursiveReadonly; - else return value as RecursiveReadonly; + const isFreezable = value !== null && typeof value === 'object'; + if (isFreezable) return deepFreeze(value) as RecursiveReadonly; + return value as RecursiveReadonly; } : (value: T) => value as RecursiveReadonly; -export const createStateContainer = < - State, - PureTransitions extends object = {}, - PureSelectors extends object = {} +export function createStateContainer( + defaultState: State +): ReduxLikeStateContainer; +export function createStateContainer( + defaultState: State, + pureTransitions: PureTransitions +): ReduxLikeStateContainer; +export function createStateContainer< + State extends BaseState, + PureTransitions extends object, + PureSelectors extends object +>( + defaultState: State, + pureTransitions: PureTransitions, + pureSelectors: PureSelectors +): ReduxLikeStateContainer; +export function createStateContainer< + State extends BaseState, + PureTransitions extends object, + PureSelectors extends object >( defaultState: State, pureTransitions: PureTransitions = {} as PureTransitions, pureSelectors: PureSelectors = {} as PureSelectors -): ReduxLikeStateContainer => { +): ReduxLikeStateContainer { const data$ = new BehaviorSubject>(freeze(defaultState)); const state$ = data$.pipe(skip(1)); const get = () => data$.getValue(); @@ -56,9 +74,13 @@ export const createStateContainer = < state$, getState: () => data$.getValue(), set: (state: State) => { - data$.next(freeze(state)); + container.dispatch({ type: $$setActionType, args: [state] }); }, reducer: (state, action) => { + if (action.type === $$setActionType) { + return freeze(action.args[0] as State); + } + const pureTransition = (pureTransitions as Record>)[ action.type ]; @@ -86,4 +108,4 @@ export const createStateContainer = < [$$observable]: state$, }; return container; -}; +} diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx index c1a35441b637b..0f25f65c30ade 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx @@ -23,15 +23,6 @@ import { act, Simulate } from 'react-dom/test-utils'; import { createStateContainer } from './create_state_container'; import { createStateContainerReactHelpers } from './create_state_container_react_helpers'; -const create = (state: S, transitions: T = {} as T) => { - const pureTransitions = { - set: () => (newState: S) => newState, - ...transitions, - }; - const store = createStateContainer(state, pureTransitions); - return { store, mutators: store.transitions }; -}; - let container: HTMLDivElement | null; beforeEach(() => { @@ -56,12 +47,12 @@ test('can create React context', () => { }); test(' passes state to ', () => { - const { store } = create({ hello: 'world' }); - const { Provider, Consumer } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ hello: 'world' }); + const { Provider, Consumer } = createStateContainerReactHelpers(); ReactDOM.render( - - {(s: typeof store) => s.get().hello} + + {(s: typeof stateContainer) => s.get().hello} , container ); @@ -79,8 +70,8 @@ interface Props1 { } test(' passes state to connect()()', () => { - const { store } = create({ hello: 'Bob' }); - const { Provider, connect } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ hello: 'Bob' }); + const { Provider, connect } = createStateContainerReactHelpers(); const Demo: React.FC = ({ message, stop }) => ( <> @@ -92,7 +83,7 @@ test(' passes state to connect()()', () => { const DemoConnected = connect(mergeProps)(Demo); ReactDOM.render( - + , container @@ -101,14 +92,14 @@ test(' passes state to connect()()', () => { expect(container!.innerHTML).toBe('Bob?'); }); -test('context receives Redux store', () => { - const { store } = create({ foo: 'bar' }); - const { Provider, context } = createStateContainerReactHelpers(); +test('context receives stateContainer', () => { + const stateContainer = createStateContainer({ foo: 'bar' }); + const { Provider, context } = createStateContainerReactHelpers(); ReactDOM.render( /* eslint-disable no-shadow */ - - {store => store.get().foo} + + {stateContainer => stateContainer.get().foo} , /* eslint-enable no-shadow */ container @@ -117,21 +108,21 @@ test('context receives Redux store', () => { expect(container!.innerHTML).toBe('bar'); }); -xtest('can use multiple stores in one React app', () => {}); +test.todo('can use multiple stores in one React app'); describe('hooks', () => { describe('useStore', () => { - test('can select store using useStore hook', () => { - const { store } = create({ foo: 'bar' }); - const { Provider, useContainer } = createStateContainerReactHelpers(); + test('can select store using useContainer hook', () => { + const stateContainer = createStateContainer({ foo: 'bar' }); + const { Provider, useContainer } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { // eslint-disable-next-line no-shadow - const store = useContainer(); - return <>{store.get().foo}; + const stateContainer = useContainer(); + return <>{stateContainer.get().foo}; }; ReactDOM.render( - + , container @@ -143,15 +134,15 @@ describe('hooks', () => { describe('useState', () => { test('can select state using useState hook', () => { - const { store } = create({ foo: 'qux' }); - const { Provider, useState } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ foo: 'qux' }); + const { Provider, useState } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const { foo } = useState(); return <>{foo}; }; ReactDOM.render( - + , container @@ -161,23 +152,20 @@ describe('hooks', () => { }); test('re-renders when state changes', () => { - const { - store, - mutators: { setFoo }, - } = create( + const stateContainer = createStateContainer( { foo: 'bar' }, { setFoo: (state: { foo: string }) => (foo: string) => ({ ...state, foo }), } ); - const { Provider, useState } = createStateContainerReactHelpers(); + const { Provider, useState } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const { foo } = useState(); return <>{foo}; }; ReactDOM.render( - + , container @@ -185,7 +173,7 @@ describe('hooks', () => { expect(container!.innerHTML).toBe('bar'); act(() => { - setFoo('baz'); + stateContainer.transitions.setFoo('baz'); }); expect(container!.innerHTML).toBe('baz'); }); @@ -193,7 +181,7 @@ describe('hooks', () => { describe('useTransitions', () => { test('useTransitions hook returns mutations that can update state', () => { - const { store } = create( + const stateContainer = createStateContainer( { cnt: 0, }, @@ -206,7 +194,7 @@ describe('hooks', () => { ); const { Provider, useState, useTransitions } = createStateContainerReactHelpers< - typeof store + typeof stateContainer >(); const Demo: React.FC<{}> = () => { const { cnt } = useState(); @@ -220,7 +208,7 @@ describe('hooks', () => { }; ReactDOM.render( - + , container @@ -240,7 +228,7 @@ describe('hooks', () => { describe('useSelector', () => { test('can select deeply nested value', () => { - const { store } = create({ + const stateContainer = createStateContainer({ foo: { bar: { baz: 'qux', @@ -248,14 +236,14 @@ describe('hooks', () => { }, }); const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const value = useSelector(selector); return <>{value}; }; ReactDOM.render( - + , container @@ -265,7 +253,7 @@ describe('hooks', () => { }); test('re-renders when state changes', () => { - const { store, mutators } = create({ + const stateContainer = createStateContainer({ foo: { bar: { baz: 'qux', @@ -280,7 +268,7 @@ describe('hooks', () => { }; ReactDOM.render( - + , container @@ -288,7 +276,7 @@ describe('hooks', () => { expect(container!.innerHTML).toBe('qux'); act(() => { - mutators.set({ + stateContainer.set({ foo: { bar: { baz: 'quux', @@ -300,9 +288,9 @@ describe('hooks', () => { }); test("re-renders only when selector's result changes", async () => { - const { store, mutators } = create({ a: 'b', foo: 'bar' }); + const stateContainer = createStateContainer({ a: 'b', foo: 'bar' }); const selector = (state: { foo: string }) => state.foo; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -311,7 +299,7 @@ describe('hooks', () => { return <>{value}; }; ReactDOM.render( - + , container @@ -321,14 +309,14 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ a: 'c', foo: 'bar' }); + stateContainer.set({ a: 'c', foo: 'bar' }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); act(() => { - mutators.set({ a: 'd', foo: 'bar 2' }); + stateContainer.set({ a: 'd', foo: 'bar 2' }); }); await new Promise(r => setTimeout(r, 1)); @@ -336,9 +324,9 @@ describe('hooks', () => { }); test('does not re-render on same shape object', async () => { - const { store, mutators } = create({ foo: { bar: 'baz' } }); + const stateContainer = createStateContainer({ foo: { bar: 'baz' } }); const selector = (state: { foo: any }) => state.foo; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -347,7 +335,7 @@ describe('hooks', () => { return <>{JSON.stringify(value)}; }; ReactDOM.render( - + , container @@ -357,14 +345,14 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'baz' } }); + stateContainer.set({ foo: { bar: 'baz' } }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'qux' } }); + stateContainer.set({ foo: { bar: 'qux' } }); }); await new Promise(r => setTimeout(r, 1)); @@ -372,7 +360,7 @@ describe('hooks', () => { }); test('can set custom comparator function to prevent re-renders on deep equality', async () => { - const { store, mutators } = create( + const stateContainer = createStateContainer( { foo: { bar: 'baz' } }, { set: () => (newState: { foo: { bar: string } }) => newState, @@ -380,7 +368,7 @@ describe('hooks', () => { ); const selector = (state: { foo: any }) => state.foo; const comparator = (prev: any, curr: any) => JSON.stringify(prev) === JSON.stringify(curr); - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -389,7 +377,7 @@ describe('hooks', () => { return <>{JSON.stringify(value)}; }; ReactDOM.render( - + , container @@ -399,13 +387,13 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'baz' } }); + stateContainer.set({ foo: { bar: 'baz' } }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); }); - xtest('unsubscribes when React un-mounts', () => {}); + test.todo('unsubscribes when React un-mounts'); }); }); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts index 45b34b13251f4..36903f2d7c90f 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts @@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = useContainer().transitions; + const useTransitions: () => Container['transitions'] = () => useContainer().transitions; const useSelector = ( selector: (state: UnboxState) => Result, diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts index e120f60e72b8f..5f27a3d2c1dca 100644 --- a/src/plugins/kibana_utils/public/state_containers/types.ts +++ b/src/plugins/kibana_utils/public/state_containers/types.ts @@ -20,12 +20,13 @@ import { Observable } from 'rxjs'; import { Ensure, RecursiveReadonly } from '@kbn/utility-types'; +export type BaseState = object; export interface TransitionDescription { type: Type; args: Args; } -export type Transition = (...args: Args) => State; -export type PureTransition = ( +export type Transition = (...args: Args) => State; +export type PureTransition = ( state: RecursiveReadonly ) => Transition; export type EnsurePureTransition = Ensure>; @@ -34,15 +35,15 @@ export type PureTransitionsToTransitions = { [K in keyof T]: PureTransitionToTransition>; }; -export interface BaseStateContainer { +export interface BaseStateContainer { get: () => RecursiveReadonly; set: (state: State) => void; state$: Observable>; } export interface StateContainer< - State, - PureTransitions extends object = {}, + State extends BaseState, + PureTransitions extends object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; @@ -50,7 +51,7 @@ export interface StateContainer< } export interface ReduxLikeStateContainer< - State, + State extends BaseState, PureTransitions extends object = {}, PureSelectors extends object = {} > extends StateContainer { @@ -63,14 +64,16 @@ export interface ReduxLikeStateContainer< } export type Dispatch = (action: T) => void; - -export type Middleware = ( +export type Middleware = ( store: Pick, 'getState' | 'dispatch'> ) => ( next: (action: TransitionDescription) => TransitionDescription | any ) => Dispatch; -export type Reducer = (state: State, action: TransitionDescription) => State; +export type Reducer = ( + state: State, + action: TransitionDescription +) => State; export type UnboxState< Container extends StateContainer @@ -80,7 +83,7 @@ export type UnboxTransitions< > = Container extends StateContainer ? T : never; export type Selector = (...args: Args) => Result; -export type PureSelector = ( +export type PureSelector = ( state: State ) => Selector; export type EnsurePureSelector = Ensure>; @@ -93,7 +96,12 @@ export type PureSelectorsToSelectors = { export type Comparator = (previous: Result, current: Result) => boolean; -export type MapStateToProps = (state: State) => StateProps; -export type Connect = ( +export type MapStateToProps = ( + state: State +) => StateProps; +export type Connect = < + Props extends object, + StatePropKeys extends keyof Props +>( mapStateToProp: MapStateToProps> ) => (component: React.ComponentType) => React.FC>; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts index cc513bc674d0f..08ad1551420d2 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { BaseStateContainer, createStateContainer } from '../state_containers'; +import { BaseState, BaseStateContainer, createStateContainer } from '../state_containers'; import { defaultState, pureTransitions, @@ -89,7 +89,7 @@ describe('state_sync', () => { // initial sync of storage to state is not happening expect(container.getState()).toEqual(defaultState); - const storageState2 = [{ id: 1, text: 'todo', completed: true }]; + const storageState2 = { todos: [{ id: 1, text: 'todo', completed: true }] }; (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); storageChange$.next(storageState2); @@ -124,7 +124,7 @@ describe('state_sync', () => { start(); const originalState = container.getState(); - const storageState = [...originalState]; + const storageState = { ...originalState }; (testStateStorage.get as jest.Mock).mockImplementation(() => storageState); storageChange$.next(storageState); @@ -134,7 +134,7 @@ describe('state_sync', () => { }); it('storage change to null should notify state', () => { - container.set([{ completed: false, id: 1, text: 'changed' }]); + container.set({ todos: [{ completed: false, id: 1, text: 'changed' }] }); const { stop, start } = syncStates([ { stateContainer: withDefaultState(container, defaultState), @@ -189,8 +189,8 @@ describe('state_sync', () => { ]); start(); - const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; - history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); + const newStateFromUrl = { todos: [{ completed: false, id: 1, text: 'changed' }] }; + history.replace('/#?_s=(todos:!((completed:!f,id:1,text:changed)))'); expect(container.getState()).toEqual(newStateFromUrl); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); @@ -220,7 +220,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); stop(); @@ -248,14 +248,14 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); await tick(); expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); stop(); @@ -294,7 +294,7 @@ describe('state_sync', () => { }); }); -function withDefaultState( +function withDefaultState( stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow defaultState: State @@ -302,7 +302,10 @@ function withDefaultState( return { ...stateContainer, set: (state: State | null) => { - stateContainer.set(state || defaultState); + stateContainer.set({ + ...defaultState, + ...state, + }); }, }; } diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts index f0ef1423dec71..9c1116e5da531 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -23,6 +23,7 @@ import defaultComparator from 'fast-deep-equal'; import { IStateSyncConfig } from './types'; import { IStateStorage } from './state_sync_state_storage'; import { distinctUntilChangedWithInitialValue } from '../../common'; +import { BaseState } from '../state_containers'; /** * Utility for syncing application state wrapped in state container @@ -86,7 +87,10 @@ export interface ISyncStateRef({ +export function syncState< + State extends BaseState, + StateStorage extends IStateStorage = IStateStorage +>({ storageKey, stateStorage, stateContainer, diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts index 0f7395ad0f0e5..3009c1d161a53 100644 --- a/src/plugins/kibana_utils/public/state_sync/types.ts +++ b/src/plugins/kibana_utils/public/state_sync/types.ts @@ -17,10 +17,11 @@ * under the License. */ -import { BaseStateContainer } from '../state_containers/types'; +import { BaseState, BaseStateContainer } from '../state_containers/types'; import { IStateStorage } from './state_sync_state_storage'; -export interface INullableBaseStateContainer extends BaseStateContainer { +export interface INullableBaseStateContainer + extends BaseStateContainer { // State container for stateSync() have to accept "null" // for example, set() implementation could handle null and fallback to some default state // this is required to handle edge case, when state in storage becomes empty and syncing is in progress. @@ -29,7 +30,7 @@ export interface INullableBaseStateContainer extends BaseStateContainer { /** diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index f9a368e85ed49..d77f4ac92da16 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -2,5 +2,6 @@ "id": "metrics", "version": "8.0.0", "kibanaVersion": "kibana", - "server": true -} \ No newline at end of file + "server": true, + "optionalPlugins": ["usageCollection"] +} diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index 599726612a936..dfb2394af237b 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -30,6 +30,8 @@ export const config = { export type VisTypeTimeseriesConfig = TypeOf; +export { ValidationTelemetryServiceSetup } from './validation_telemetry'; + export function plugin(initializerContext: PluginInitializerContext) { return new VisTypeTimeseriesPlugin(initializerContext); } diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index f508aa250454f..dcd0cd500bbc3 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -35,11 +35,17 @@ import { GetVisData, GetVisDataOptions, } from '../../../legacy/core_plugins/vis_type_timeseries/server'; +import { ValidationTelemetryService } from './validation_telemetry/validation_telemetry_service'; +import { UsageCollectionSetup } from '../../usage_collection/server'; export interface LegacySetup { server: Server; } +interface VisTypeTimeseriesPluginSetupDependencies { + usageCollection?: UsageCollectionSetup; +} + export interface VisTypeTimeseriesSetup { /** @deprecated */ __legacy: { @@ -61,11 +67,14 @@ export interface Framework { } export class VisTypeTimeseriesPlugin implements Plugin { + private validationTelementryService: ValidationTelemetryService; + constructor(private readonly initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; + this.validationTelementryService = new ValidationTelemetryService(); } - public setup(core: CoreSetup, plugins: any) { + public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) { const logger = this.initializerContext.logger.get('visTypeTimeseries'); const config$ = this.initializerContext.config.create(); // Global config contains things like the ES shard timeout @@ -82,8 +91,13 @@ export class VisTypeTimeseriesPlugin implements Plugin { return { __legacy: { config$, - registerLegacyAPI: once((__LEGACY: LegacySetup) => { - init(framework, __LEGACY); + registerLegacyAPI: once(async (__LEGACY: LegacySetup) => { + const validationTelemetrySetup = await this.validationTelementryService.setup(core, { + ...plugins, + globalConfig$, + }); + + await init(framework, __LEGACY, validationTelemetrySetup); }), }, getVisData: async (requestContext: RequestHandlerContext, options: GetVisDataOptions) => { diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts new file mode 100644 index 0000000000000..140f61fa2f3fd --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './validation_telemetry_service'; diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts new file mode 100644 index 0000000000000..136f5b9e5cfad --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { APICaller, CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server'; +import { UsageCollectionSetup } from '../../../usage_collection/server'; + +export interface ValidationTelemetryServiceSetup { + logFailedValidation: () => void; +} + +export class ValidationTelemetryService implements Plugin { + private kibanaIndex: string = ''; + async setup( + core: CoreSetup, + { + usageCollection, + globalConfig$, + }: { + usageCollection?: UsageCollectionSetup; + globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$']; + } + ) { + globalConfig$.subscribe(config => { + this.kibanaIndex = config.kibana.index; + }); + if (usageCollection) { + usageCollection.registerCollector( + usageCollection.makeUsageCollector({ + type: 'tsvb-validation', + isReady: () => this.kibanaIndex !== '', + fetch: async (callCluster: APICaller) => { + try { + const response = await callCluster('get', { + index: this.kibanaIndex, + id: 'tsvb-validation-telemetry:tsvb-validation-telemetry', + ignore: [404], + }); + return { + failed_validations: + response?._source?.['tsvb-validation-telemetry']?.failedRequests || 0, + }; + } catch (err) { + return { + failed_validations: 0, + }; + } + }, + }) + ); + } + const internalRepository = core.savedObjects.createInternalRepository(); + + return { + logFailedValidation: async () => { + try { + await internalRepository.incrementCounter( + 'tsvb-validation-telemetry', + 'tsvb-validation-telemetry', + 'failedRequests' + ); + } catch (e) { + // swallow error, validation telemetry shouldn't fail anything else + } + }, + }; + } + start() {} +} diff --git a/tasks/config/karma.js b/tasks/config/karma.js index 0acd452530b30..ec37277cae0f8 100644 --- a/tasks/config/karma.js +++ b/tasks/config/karma.js @@ -21,6 +21,7 @@ import { dirname } from 'path'; import { times } from 'lodash'; import { makeJunitReportPath } from '@kbn/test'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; +import { DllCompiler } from '../../src/optimize/dynamic_dll_plugin'; const TOTAL_CI_SHARDS = 4; const ROOT = dirname(require.resolve('../../package.json')); @@ -54,7 +55,10 @@ module.exports = function(grunt) { 'http://localhost:5610/test_bundle/built_css.css', `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.distFilename}`, - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', + 'http://localhost:5610/built_assets/dlls/vendors_runtime.bundle.dll.js', + ...DllCompiler.getRawDllConfig().chunks.map( + chunk => `http://localhost:5610/built_assets/dlls/vendors${chunk}.bundle.dll.js` + ), shardNum === undefined ? `http://localhost:5610/bundles/tests.bundle.js` @@ -63,7 +67,9 @@ module.exports = function(grunt) { // this causes tilemap tests to fail, probably because the eui styles haven't been // included in the karma harness a long some time, if ever // `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', + ...DllCompiler.getRawDllConfig().chunks.map( + chunk => `http://localhost:5610/built_assets/dlls/vendors${chunk}.style.dll.css` + ), 'http://localhost:5610/bundles/tests.style.css', ]; } diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 38ee5b7db39c4..e25d295515971 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -20,10 +20,12 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'timePicker']); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'share', 'timePicker']); const a11y = getService('a11y'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const inspector = getService('inspector'); + const filterBar = getService('filterBar'); describe('Discover', () => { before(async () => { @@ -39,5 +41,73 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { it('main view', async () => { await a11y.testAppSnapshot(); }); + + it('Click save button', async () => { + await PageObjects.discover.clickSaveSearchButton(); + await a11y.testAppSnapshot(); + }); + + it('Save search panel', async () => { + await PageObjects.discover.inputSavedSearchTitle('a11ySearch'); + await a11y.testAppSnapshot(); + }); + + it('Confirm saved search', async () => { + await PageObjects.discover.clickConfirmSavedSearch(); + await a11y.testAppSnapshot(); + }); + + // skipping the test for new because we can't fix it right now + it.skip('Click on new to clear the search', async () => { + await PageObjects.discover.clickNewSearchButton(); + await a11y.testAppSnapshot(); + }); + + it('Open load saved search panel', async () => { + await PageObjects.discover.openLoadSavedSearchPanel(); + await a11y.testAppSnapshot(); + await PageObjects.discover.closeLoadSavedSearchPanel(); + }); + + it('Open inspector panel', async () => { + await inspector.open(); + await a11y.testAppSnapshot(); + await inspector.close(); + }); + + it('Open add filter', async () => { + await PageObjects.discover.openAddFilterPanel(); + await a11y.testAppSnapshot(); + }); + + it('Select values for a filter', async () => { + await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); + await a11y.testAppSnapshot(); + }); + + it('Load a new search from the panel', async () => { + await PageObjects.discover.clickSaveSearchButton(); + await PageObjects.discover.inputSavedSearchTitle('filterSearch'); + await PageObjects.discover.clickConfirmSavedSearch(); + await PageObjects.discover.openLoadSavedSearchPanel(); + await PageObjects.discover.loadSavedSearch('filterSearch'); + await a11y.testAppSnapshot(); + }); + + // unable to validate on EUI pop-over + it('click share button', async () => { + await PageObjects.share.clickShareTopNavButton(); + await a11y.testAppSnapshot(); + }); + + it('Open sidebar filter', async () => { + await PageObjects.discover.openSidebarFieldFilter(); + await a11y.testAppSnapshot(); + }); + + it('Close sidebar filter', async () => { + await PageObjects.discover.closeSidebarFieldFilter(); + await a11y.testAppSnapshot(); + }); }); } diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index 7adfe7ebfcc7d..72440b648e538 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -45,7 +45,6 @@ export const normalizeResult = (report: any) => { export function A11yProvider({ getService }: FtrProviderContext) { const browser = getService('browser'); const Wd = getService('__webdriver__'); - const log = getService('log'); /** * Accessibility testing service using the Axe (https://www.deque.com/axe/) @@ -78,11 +77,6 @@ export function A11yProvider({ getService }: FtrProviderContext) { private testAxeReport(report: AxeReport) { const errorMsgs = []; - for (const result of report.incomplete) { - // these items require human review and can't be definitively validated - log.warning(printResult(chalk.yellow('UNABLE TO VALIDATE'), result)); - } - for (const result of report.violations) { errorMsgs.push(printResult(chalk.red('VIOLATION'), result)); } diff --git a/test/functional/apps/context/_date_nanos_custom_timestamp.js b/test/functional/apps/context/_date_nanos_custom_timestamp.js new file mode 100644 index 0000000000000..3901fa936e719 --- /dev/null +++ b/test/functional/apps/context/_date_nanos_custom_timestamp.js @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; + +const TEST_INDEX_PATTERN = 'date_nanos_custom_timestamp'; +const TEST_DEFAULT_CONTEXT_SIZE = 1; +const TEST_STEP_SIZE = 3; + +export default function({ getService, getPageObjects }) { + const kibanaServer = getService('kibanaServer'); + const docTable = getService('docTable'); + const PageObjects = getPageObjects(['common', 'context', 'timePicker', 'discover']); + const esArchiver = getService('esArchiver'); + + describe('context view for date_nanos with custom timestamp', () => { + before(async function() { + await esArchiver.loadIfNeeded('date_nanos_custom'); + await kibanaServer.uiSettings.replace({ defaultIndex: TEST_INDEX_PATTERN }); + await kibanaServer.uiSettings.update({ + 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, + 'context:step': `${TEST_STEP_SIZE}`, + }); + }); + + after(function unloadMakelogs() { + return esArchiver.unload('date_nanos_custom'); + }); + + it('displays predessors - anchor - successors in right order ', async function() { + await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, '1'); + const actualRowsText = await docTable.getRowsText(); + const expectedRowsText = [ + 'Oct 21, 2019 @ 08:30:04.828733000 -', + 'Oct 21, 2019 @ 00:30:04.828740000 -', + 'Oct 21, 2019 @ 00:30:04.828723000 -', + ]; + expect(actualRowsText).to.eql(expectedRowsText); + }); + }); +} diff --git a/test/functional/apps/context/index.js b/test/functional/apps/context/index.js index 9e1b04ad45874..c3c938c623731 100644 --- a/test/functional/apps/context/index.js +++ b/test/functional/apps/context/index.js @@ -42,5 +42,6 @@ export default function({ getService, getPageObjects, loadTestFile }) { loadTestFile(require.resolve('./_filters')); loadTestFile(require.resolve('./_size')); loadTestFile(require.resolve('./_date_nanos')); + loadTestFile(require.resolve('./_date_nanos_custom_timestamp')); }); } diff --git a/test/functional/apps/dashboard/dashboard_clone.js b/test/functional/apps/dashboard/dashboard_clone.js index 2a955a2dc90b1..f5485c1db206e 100644 --- a/test/functional/apps/dashboard/dashboard_clone.js +++ b/test/functional/apps/dashboard/dashboard_clone.js @@ -21,6 +21,7 @@ import expect from '@kbn/expect'; export default function({ getService, getPageObjects }) { const retry = getService('retry'); + const listingTable = getService('listingTable'); const PageObjects = getPageObjects(['dashboard', 'header', 'common']); describe('dashboard clone', function describeIndexTests() { @@ -40,10 +41,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.clickClone(); await PageObjects.dashboard.confirmClone(); - - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + await PageObjects.dashboard.gotoDashboardLandingPage(); + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', clonedDashboardName ); + expect(countOfDashboards).to.equal(1); }); @@ -70,8 +73,10 @@ export default function({ getService, getPageObjects }) { it("and doesn't save", async () => { await PageObjects.dashboard.cancelClone(); + await PageObjects.dashboard.gotoDashboardLandingPage(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName ); expect(countOfDashboards).to.equal(1); @@ -85,8 +90,10 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.expectDuplicateTitleWarningDisplayed({ displayed: true }); await PageObjects.dashboard.confirmClone(); await PageObjects.dashboard.waitForRenderComplete(); + await PageObjects.dashboard.gotoDashboardLandingPage(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName + ' Copy' ); expect(countOfDashboards).to.equal(2); diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index 5dcb18374c51f..6d2a30fa85325 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -27,7 +27,7 @@ export default function({ getService, getPageObjects }) { const pieChart = getService('pieChart'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize']); + const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); describe('dashboard filter bar', () => { before(async () => { @@ -91,7 +91,7 @@ export default function({ getService, getPageObjects }) { await filterBar.ensureFieldEditorModalIsClosed(); await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.timePicker.setDefaultDataRange(); }); it('are not selected by default', async function() { @@ -136,7 +136,7 @@ export default function({ getService, getPageObjects }) { await filterBar.ensureFieldEditorModalIsClosed(); await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.timePicker.setDefaultDataRange(); }); it('are added when a cell magnifying glass is clicked', async function() { diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index bd31bb010f260..1cb9f1490d442 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -34,7 +34,7 @@ export default function({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardPanelActions = getService('dashboardPanelActions'); - const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize']); + const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); describe('dashboard filtering', function() { this.tags('smoke'); @@ -52,7 +52,7 @@ export default function({ getService, getPageObjects }) { describe('adding a filter that excludes all data', () => { before(async () => { await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.timePicker.setDefaultDataRange(); await dashboardAddPanel.addEveryVisualization('"Filter Bytes Test"'); await dashboardAddPanel.addEverySavedSearch('"Filter Bytes Test"'); @@ -234,7 +234,7 @@ export default function({ getService, getPageObjects }) { it('visualization saved with a query filters data', async () => { await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.timePicker.setDefaultDataRange(); await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/test/functional/apps/dashboard/dashboard_listing.js b/test/functional/apps/dashboard/dashboard_listing.js index 179f10223afb2..e3e835109da2c 100644 --- a/test/functional/apps/dashboard/dashboard_listing.js +++ b/test/functional/apps/dashboard/dashboard_listing.js @@ -22,6 +22,7 @@ import expect from '@kbn/expect'; export default function({ getService, getPageObjects }) { const PageObjects = getPageObjects(['dashboard', 'header', 'common']); const browser = getService('browser'); + const listingTable = getService('listingTable'); describe('dashboard listing page', function describeIndexTests() { const dashboardName = 'Dashboard Listing Test'; @@ -41,7 +42,8 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.saveDashboard(dashboardName); await PageObjects.dashboard.gotoDashboardLandingPage(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName ); expect(countOfDashboards).to.equal(1); @@ -53,7 +55,8 @@ export default function({ getService, getPageObjects }) { }); it('is not shown when there are no dashboards shown during a search', async function() { - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', 'gobeldeguck' ); expect(countOfDashboards).to.equal(0); @@ -65,9 +68,9 @@ export default function({ getService, getPageObjects }) { describe('delete', function() { it('default confirm action is cancel', async function() { - await PageObjects.dashboard.searchForDashboardWithName(dashboardName); - await PageObjects.dashboard.checkDashboardListingSelectAllCheckbox(); - await PageObjects.dashboard.clickDeleteSelectedDashboards(); + await listingTable.searchForItemWithName(dashboardName); + await listingTable.checkListingSelectAllCheckbox(); + await listingTable.clickDeleteSelected(); await PageObjects.common.expectConfirmModalOpenState(true); @@ -75,19 +78,21 @@ export default function({ getService, getPageObjects }) { await PageObjects.common.expectConfirmModalOpenState(false); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName ); expect(countOfDashboards).to.equal(1); }); it('succeeds on confirmation press', async function() { - await PageObjects.dashboard.checkDashboardListingSelectAllCheckbox(); - await PageObjects.dashboard.clickDeleteSelectedDashboards(); + await listingTable.checkListingSelectAllCheckbox(); + await listingTable.clickDeleteSelected(); await PageObjects.common.clickConfirmOnModal(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName ); expect(countOfDashboards).to.equal(0); @@ -96,44 +101,45 @@ export default function({ getService, getPageObjects }) { describe('search', function() { before(async () => { - await PageObjects.dashboard.clearSearchValue(); + await listingTable.clearSearchFilter(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.dashboard.saveDashboard('Two Words'); + await PageObjects.dashboard.gotoDashboardLandingPage(); }); it('matches on the first word', async function() { - await PageObjects.dashboard.searchForDashboardWithName('Two'); - const countOfDashboards = await PageObjects.dashboard.getCountOfDashboardsInListingTable(); + await listingTable.searchForItemWithName('Two'); + const countOfDashboards = await listingTable.getItemsCount('dashboard'); expect(countOfDashboards).to.equal(1); }); it('matches the second word', async function() { - await PageObjects.dashboard.searchForDashboardWithName('Words'); - const countOfDashboards = await PageObjects.dashboard.getCountOfDashboardsInListingTable(); + await listingTable.searchForItemWithName('Words'); + const countOfDashboards = await listingTable.getItemsCount('dashboard'); expect(countOfDashboards).to.equal(1); }); it('matches the second word prefix', async function() { - await PageObjects.dashboard.searchForDashboardWithName('Wor'); - const countOfDashboards = await PageObjects.dashboard.getCountOfDashboardsInListingTable(); + await listingTable.searchForItemWithName('Wor'); + const countOfDashboards = await listingTable.getItemsCount('dashboard'); expect(countOfDashboards).to.equal(1); }); it('does not match mid word', async function() { - await PageObjects.dashboard.searchForDashboardWithName('ords'); - const countOfDashboards = await PageObjects.dashboard.getCountOfDashboardsInListingTable(); + await listingTable.searchForItemWithName('ords'); + const countOfDashboards = await listingTable.getItemsCount('dashboard'); expect(countOfDashboards).to.equal(0); }); it('is case insensitive', async function() { - await PageObjects.dashboard.searchForDashboardWithName('two words'); - const countOfDashboards = await PageObjects.dashboard.getCountOfDashboardsInListingTable(); + await listingTable.searchForItemWithName('two words'); + const countOfDashboards = await listingTable.getItemsCount('dashboard'); expect(countOfDashboards).to.equal(1); }); it('is using AND operator', async function() { - await PageObjects.dashboard.searchForDashboardWithName('three words'); - const countOfDashboards = await PageObjects.dashboard.getCountOfDashboardsInListingTable(); + await listingTable.searchForItemWithName('three words'); + const countOfDashboards = await listingTable.getItemsCount('dashboard'); expect(countOfDashboards).to.equal(0); }); }); @@ -176,7 +182,7 @@ export default function({ getService, getPageObjects }) { }); it('preloads search filter bar when there is no match', async function() { - const searchFilter = await PageObjects.dashboard.getSearchFilterValue(); + const searchFilter = await listingTable.getSearchFilterValue(); expect(searchFilter).to.equal('"nodashboardsnamedme"'); }); @@ -196,7 +202,7 @@ export default function({ getService, getPageObjects }) { }); it('preloads search filter bar when there is more than one match', async function() { - const searchFilter = await PageObjects.dashboard.getSearchFilterValue(); + const searchFilter = await listingTable.getSearchFilterValue(); expect(searchFilter).to.equal('"two words"'); }); diff --git a/test/functional/apps/dashboard/dashboard_save.js b/test/functional/apps/dashboard/dashboard_save.js index 23bb784c79cd0..2ea1389b89ad4 100644 --- a/test/functional/apps/dashboard/dashboard_save.js +++ b/test/functional/apps/dashboard/dashboard_save.js @@ -19,8 +19,9 @@ import expect from '@kbn/expect'; -export default function({ getPageObjects }) { +export default function({ getPageObjects, getService }) { const PageObjects = getPageObjects(['dashboard', 'header']); + const listingTable = getService('listingTable'); describe('dashboard save', function describeIndexTests() { this.tags('smoke'); @@ -47,8 +48,10 @@ export default function({ getPageObjects }) { it('does not save on reject confirmation', async function() { await PageObjects.dashboard.cancelSave(); + await PageObjects.dashboard.gotoDashboardLandingPage(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName ); expect(countOfDashboards).to.equal(1); @@ -68,15 +71,17 @@ export default function({ getPageObjects }) { // wait till it finishes reloading or it might reload the url after simulating the // dashboard landing page click. await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.gotoDashboardLandingPage(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardName ); expect(countOfDashboards).to.equal(2); }); it('Does not warn when you save an existing dashboard with the title it already has, and that title is a duplicate', async function() { - await PageObjects.dashboard.selectDashboard(dashboardName); + await listingTable.clickItemLink('dashboard', dashboardName); await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); await PageObjects.dashboard.switchToEditMode(); await PageObjects.dashboard.saveDashboard(dashboardName); @@ -121,8 +126,10 @@ export default function({ getPageObjects }) { // wait till it finishes reloading or it might reload the url after simulating the // dashboard landing page click. await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.gotoDashboardLandingPage(); - const countOfDashboards = await PageObjects.dashboard.getDashboardCountWithName( + const countOfDashboards = await listingTable.searchAndGetItemsCount( + 'dashboard', dashboardNameEnterKey ); expect(countOfDashboards).to.equal(1); diff --git a/test/functional/apps/dashboard/dashboard_snapshots.js b/test/functional/apps/dashboard/dashboard_snapshots.js index 9900881e4690d..3a09b46a713cc 100644 --- a/test/functional/apps/dashboard/dashboard_snapshots.js +++ b/test/functional/apps/dashboard/dashboard_snapshots.js @@ -20,7 +20,7 @@ import expect from '@kbn/expect'; export default function({ getService, getPageObjects, updateBaselines }) { - const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common']); + const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'common', 'timePicker']); const screenshot = getService('screenshots'); const browser = getService('browser'); const esArchiver = getService('esArchiver'); @@ -48,7 +48,7 @@ export default function({ getService, getPageObjects, updateBaselines }) { it('compare TSVB snapshot', async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInLogstashDataRange(); + await PageObjects.timePicker.setLogstashDataRange(); await dashboardAddPanel.addVisualization('Rendering Test: tsvb-ts'); await PageObjects.common.closeToast(); @@ -71,7 +71,7 @@ export default function({ getService, getPageObjects, updateBaselines }) { it('compare area chart snapshot', async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInLogstashDataRange(); + await PageObjects.timePicker.setLogstashDataRange(); await dashboardAddPanel.addVisualization('Rendering Test: area with not filter'); await PageObjects.common.closeToast(); diff --git a/test/functional/apps/dashboard/dashboard_state.js b/test/functional/apps/dashboard/dashboard_state.js index 3b9e404e9b94d..b9172990c501d 100644 --- a/test/functional/apps/dashboard/dashboard_state.js +++ b/test/functional/apps/dashboard/dashboard_state.js @@ -34,6 +34,7 @@ export default function({ getService, getPageObjects }) { 'discover', 'tileMap', 'visChart', + 'timePicker', ]); const testSubjects = getService('testSubjects'); const browser = getService('browser'); @@ -58,7 +59,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); await dashboardAddPanel.addVisualization(AREA_CHART_VIS_NAME); await PageObjects.dashboard.saveDashboard('Overridden colors'); @@ -83,7 +84,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.header.clickDiscover(); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); await PageObjects.discover.clickFieldListItemAdd('bytes'); await PageObjects.discover.saveSearch('my search'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -147,7 +148,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); await dashboardAddPanel.addVisualization('Visualization TileMap'); await PageObjects.dashboard.saveDashboard('No local edits'); diff --git a/test/functional/apps/dashboard/dashboard_time_picker.js b/test/functional/apps/dashboard/dashboard_time_picker.js index 0b73bc224ab74..b99de9fee6db1 100644 --- a/test/functional/apps/dashboard/dashboard_time_picker.js +++ b/test/functional/apps/dashboard/dashboard_time_picker.js @@ -44,7 +44,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.addVisualizations([PIE_CHART_VIS_NAME]); await pieChart.expectPieSliceCount(0); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); await pieChart.expectPieSliceCount(10); }); @@ -95,7 +95,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.dashboard.addVisualizations([PIE_CHART_VIS_NAME]); - // Same date range as `setTimepickerInHistoricalDataRange` + // Same date range as `timePicker.setHistoricalDataRange()` await PageObjects.timePicker.setAbsoluteRange( '2015-09-19 06:31:44.000', '2015-09-23 18:31:44.000' diff --git a/test/functional/apps/dashboard/panel_controls.js b/test/functional/apps/dashboard/panel_controls.js index 683f3683e65e5..f30f58913bd97 100644 --- a/test/functional/apps/dashboard/panel_controls.js +++ b/test/functional/apps/dashboard/panel_controls.js @@ -33,7 +33,13 @@ export default function({ getService, getPageObjects }) { const dashboardReplacePanel = getService('dashboardReplacePanel'); const dashboardVisualizations = getService('dashboardVisualizations'); const renderable = getService('renderable'); - const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'discover']); + const PageObjects = getPageObjects([ + 'dashboard', + 'header', + 'visualize', + 'discover', + 'timePicker', + ]); const dashboardName = 'Dashboard Panel Controls Test'; describe('dashboard panel controls', function viewEditModeTests() { @@ -52,7 +58,7 @@ export default function({ getService, getPageObjects }) { let intialDimensions; before(async () => { await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); await dashboardAddPanel.addVisualization(LINE_CHART_VIS_NAME); intialDimensions = await PageObjects.dashboard.getPanelDimensions(); @@ -110,7 +116,7 @@ export default function({ getService, getPageObjects }) { describe('panel edit controls', function() { before(async () => { await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); }); diff --git a/test/functional/apps/dashboard/view_edit.js b/test/functional/apps/dashboard/view_edit.js index 212044d898251..a0b972f3ab63c 100644 --- a/test/functional/apps/dashboard/view_edit.js +++ b/test/functional/apps/dashboard/view_edit.js @@ -68,7 +68,7 @@ export default function({ getService, getPageObjects }) { }); it('when time changed is stored with dashboard', async function() { - await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.timePicker.setDefaultDataRange(); const originalTime = await PageObjects.timePicker.getTimeConfig(); @@ -196,7 +196,7 @@ export default function({ getService, getPageObjects }) { describe('and preserves edits on cancel', function() { it('when time changed is stored with dashboard', async function() { await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); - await PageObjects.dashboard.setTimepickerInDataRange(); + await PageObjects.timePicker.setDefaultDataRange(); await PageObjects.dashboard.saveDashboard(dashboardName, true); await PageObjects.dashboard.switchToEditMode(); await PageObjects.timePicker.setAbsoluteRange( diff --git a/test/functional/apps/home/_sample_data.js b/test/functional/apps/home/_sample_data.ts similarity index 94% rename from test/functional/apps/home/_sample_data.js rename to test/functional/apps/home/_sample_data.ts index 4aa862a4a0384..8088b5a0f9da9 100644 --- a/test/functional/apps/home/_sample_data.js +++ b/test/functional/apps/home/_sample_data.ts @@ -19,8 +19,9 @@ import expect from '@kbn/expect'; import moment from 'moment'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function({ getService, getPageObjects }) { +export default function({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const find = getService('find'); const log = getService('log'); @@ -76,9 +77,8 @@ export default function({ getService, getPageObjects }) { expect(isInstalled).to.be(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/40670 - describe.skip('dashboard', () => { - afterEach(async () => { + describe('dashboard', () => { + beforeEach(async () => { await PageObjects.common.navigateToUrl('home', 'tutorial_directory/sampleData'); await PageObjects.header.waitUntilLoadingHasFinished(); }); @@ -99,7 +99,6 @@ export default function({ getService, getPageObjects }) { await PageObjects.home.launchSampleDataSet('flights'); await PageObjects.header.waitUntilLoadingHasFinished(); await renderable.waitForRender(); - log.debug('Checking pie charts rendered'); await pieChart.expectPieSliceCount(4); log.debug('Checking area, bar and heatmap charts rendered'); @@ -142,6 +141,11 @@ export default function({ getService, getPageObjects }) { // needs to be in describe block so it is run after 'dashboard describe block' describe('uninstall', () => { + beforeEach(async () => { + await PageObjects.common.navigateToUrl('home', 'tutorial_directory/sampleData'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + it('should uninstall flights sample data set', async () => { await PageObjects.home.removeSampleDataSet('flights'); const isInstalled = await PageObjects.home.isSampleDataSetInstalled('flights'); diff --git a/test/functional/fixtures/es_archiver/date_nanos_custom/data.json b/test/functional/fixtures/es_archiver/date_nanos_custom/data.json new file mode 100644 index 0000000000000..73cba70a8b93d --- /dev/null +++ b/test/functional/fixtures/es_archiver/date_nanos_custom/data.json @@ -0,0 +1,56 @@ +{ + "type": "doc", + "value": { + "id": "index-pattern:date_nanos_custom_timestamp", + "index": ".kibana", + "source": { + "index-pattern": { + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"esTypes\":[\"_id\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"esTypes\":[\"_index\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"esTypes\":[\"_source\"],\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"esTypes\":[\"_type\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"test\",\"type\":\"string\",\"esTypes\":[\"text\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"test.keyword\",\"type\":\"string\",\"esTypes\":[\"keyword\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true,\"subType\":{\"multi\":{\"parent\":\"test\"}}},{\"name\":\"timestamp\",\"type\":\"date\",\"esTypes\":[\"date_nanos\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "timeFieldName": "timestamp", + "title": "date_nanos_custom_timestamp" + }, + "references": [ + ], + "type": "index-pattern", + "updated_at": "2020-01-09T21:43:20.283Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "1", + "index": "date_nanos_custom_timestamp", + "source": { + "test": "1", + "timestamp": "2019-10-21 00:30:04.828740" + } + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "date_nanos_custom_timestamp", + "source": { + "test": "1", + "timestamp": "2019-10-21 08:30:04.828733" + } + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "date_nanos_custom_timestamp", + "source": { + "test": "1", + "timestamp": "2019-10-21 00:30:04.828723" + } + } +} + + diff --git a/test/functional/fixtures/es_archiver/date_nanos_custom/mappings.json b/test/functional/fixtures/es_archiver/date_nanos_custom/mappings.json new file mode 100644 index 0000000000000..98af509c4e6f2 --- /dev/null +++ b/test/functional/fixtures/es_archiver/date_nanos_custom/mappings.json @@ -0,0 +1,31 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "date_nanos_custom_timestamp", + "mappings": { + "properties": { + "test": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "timestamp": { + "format": "yyyy-MM-dd HH:mm:ss.SSSSSS", + "type": "date_nanos" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/test/functional/page_objects/dashboard_page.js b/test/functional/page_objects/dashboard_page.ts similarity index 60% rename from test/functional/page_objects/dashboard_page.js rename to test/functional/page_objects/dashboard_page.ts index b0f1a3304a9b8..af0a0160a81d8 100644 --- a/test/functional/page_objects/dashboard_page.js +++ b/test/functional/page_objects/dashboard_page.ts @@ -17,89 +17,85 @@ * under the License. */ -import _ from 'lodash'; import { DashboardConstants } from '../../../src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants'; export const PIE_CHART_VIS_NAME = 'Visualization PieChart'; export const AREA_CHART_VIS_NAME = 'Visualization漢字 AreaChart'; export const LINE_CHART_VIS_NAME = 'Visualization漢字 LineChart'; +import { FtrProviderContext } from '../ftr_provider_context'; -export function DashboardPageProvider({ getService, getPageObjects }) { +export function DashboardPageProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const find = getService('find'); const retry = getService('retry'); - const config = getService('config'); const browser = getService('browser'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); const renderable = getService('renderable'); - const PageObjects = getPageObjects(['common', 'header', 'settings', 'visualize', 'timePicker']); - - const defaultFindTimeout = config.get('timeouts.find'); + const listingTable = getService('listingTable'); + const PageObjects = getPageObjects(['common', 'header', 'visualize']); + + interface SaveDashboardOptions { + waitDialogIsClosed: boolean; + needsConfirm?: boolean; + storeTimeWithDashboard?: boolean; + saveAsNew?: boolean; + } class DashboardPage { async initTests({ kibanaIndex = 'dashboard/legacy', defaultIndex = 'logstash-*' } = {}) { log.debug('load kibana index with visualizations and log data'); await esArchiver.load(kibanaIndex); - await kibanaServer.uiSettings.replace({ - defaultIndex: defaultIndex, - }); + await kibanaServer.uiSettings.replace({ defaultIndex }); await PageObjects.common.navigateToApp('dashboard'); } - async preserveCrossAppState() { + public async preserveCrossAppState() { const url = await browser.getCurrentUrl(); await browser.get(url, false); await PageObjects.header.waitUntilLoadingHasFinished(); } - async selectDefaultIndex(indexName) { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await find.clickByPartialLinkText(indexName); - await PageObjects.settings.clickDefaultIndexButton(); - } - - async clickFullScreenMode() { + public async clickFullScreenMode() { log.debug(`clickFullScreenMode`); await testSubjects.click('dashboardFullScreenMode'); await testSubjects.exists('exitFullScreenModeLogo'); await this.waitForRenderComplete(); } - async fullScreenModeMenuItemExists() { + public async fullScreenModeMenuItemExists() { return await testSubjects.exists('dashboardFullScreenMode'); } - async exitFullScreenTextButtonExists() { + public async exitFullScreenTextButtonExists() { return await testSubjects.exists('exitFullScreenModeText'); } - async getExitFullScreenTextButton() { + public async getExitFullScreenTextButton() { return await testSubjects.find('exitFullScreenModeText'); } - async exitFullScreenLogoButtonExists() { + public async exitFullScreenLogoButtonExists() { return await testSubjects.exists('exitFullScreenModeLogo'); } - async getExitFullScreenLogoButton() { + public async getExitFullScreenLogoButton() { return await testSubjects.find('exitFullScreenModeLogo'); } - async clickExitFullScreenLogoButton() { + public async clickExitFullScreenLogoButton() { await testSubjects.click('exitFullScreenModeLogo'); await this.waitForRenderComplete(); } - async clickExitFullScreenTextButton() { + public async clickExitFullScreenTextButton() { await testSubjects.click('exitFullScreenModeText'); await this.waitForRenderComplete(); } - async getDashboardIdFromCurrentUrl() { + public async getDashboardIdFromCurrentUrl() { const currentUrl = await browser.getCurrentUrl(); const urlSubstring = 'kibana#/dashboard/'; const startOfIdIndex = currentUrl.indexOf(urlSubstring) + urlSubstring.length; @@ -115,25 +111,25 @@ export function DashboardPageProvider({ getService, getPageObjects }) { * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). * @returns {Promise} */ - async onDashboardLandingPage() { + public async onDashboardLandingPage() { log.debug(`onDashboardLandingPage`); return await testSubjects.exists('dashboardLandingPage', { timeout: 5000, }); } - async expectExistsDashboardLandingPage() { + public async expectExistsDashboardLandingPage() { log.debug(`expectExistsDashboardLandingPage`); await testSubjects.existOrFail('dashboardLandingPage'); } - async clickDashboardBreadcrumbLink() { + public async clickDashboardBreadcrumbLink() { log.debug('clickDashboardBreadcrumbLink'); await find.clickByCssSelector(`a[href="#${DashboardConstants.LANDING_PAGE_PATH}"]`); await this.expectExistsDashboardLandingPage(); } - async gotoDashboardLandingPage() { + public async gotoDashboardLandingPage() { log.debug('gotoDashboardLandingPage'); const onPage = await this.onDashboardLandingPage(); if (!onPage) { @@ -141,26 +137,26 @@ export function DashboardPageProvider({ getService, getPageObjects }) { } } - async clickClone() { + public async clickClone() { log.debug('Clicking clone'); await testSubjects.click('dashboardClone'); } - async getCloneTitle() { + public async getCloneTitle() { return await testSubjects.getAttribute('clonedDashboardTitle', 'value'); } - async confirmClone() { + public async confirmClone() { log.debug('Confirming clone'); await testSubjects.click('cloneConfirmButton'); } - async cancelClone() { + public async cancelClone() { log.debug('Canceling clone'); await testSubjects.click('cloneCancelButton'); } - async setClonedDashboardTitle(title) { + public async setClonedDashboardTitle(title: string) { await testSubjects.setValue('clonedDashboardTitle', title); } @@ -168,7 +164,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) { * Asserts that the duplicate title warning is either displayed or not displayed. * @param { displayed: boolean } */ - async expectDuplicateTitleWarningDisplayed({ displayed }) { + public async expectDuplicateTitleWarningDisplayed({ displayed = true }) { if (displayed) { await testSubjects.existOrFail('titleDupicateWarnMsg'); } else { @@ -180,18 +176,16 @@ export function DashboardPageProvider({ getService, getPageObjects }) { * Asserts that the toolbar pagination (count and arrows) is either displayed or not displayed. * @param { displayed: boolean } */ - async expectToolbarPaginationDisplayed({ displayed }) { + public async expectToolbarPaginationDisplayed({ displayed = true }) { const subjects = ['btnPrevPage', 'btnNextPage', 'toolBarPagerText']; if (displayed) { - return await Promise.all(subjects.map(async subj => await testSubjects.existOrFail(subj))); + await Promise.all(subjects.map(async subj => await testSubjects.existOrFail(subj))); } else { - return await Promise.all( - subjects.map(async subj => await testSubjects.missingOrFail(subj)) - ); + await Promise.all(subjects.map(async subj => await testSubjects.missingOrFail(subj))); } } - async switchToEditMode() { + public async switchToEditMode() { log.debug('Switching to edit mode'); await testSubjects.click('dashboardEditMode'); // wait until the count of dashboard panels equals the count of toggle menu icons @@ -204,66 +198,34 @@ export function DashboardPageProvider({ getService, getPageObjects }) { }); } - async getIsInViewMode() { + public async getIsInViewMode() { log.debug('getIsInViewMode'); return await testSubjects.exists('dashboardEditMode'); } - async clickCancelOutOfEditMode() { + public async clickCancelOutOfEditMode() { log.debug('clickCancelOutOfEditMode'); - return await testSubjects.click('dashboardViewOnlyMode'); + await testSubjects.click('dashboardViewOnlyMode'); } - async clickNewDashboard() { - // One or the other will eventually show up on the landing page, depending on whether there are - // dashboards. - await retry.try(async () => { - const createNewItemButtonExists = await testSubjects.exists('newItemButton'); - if (createNewItemButtonExists) { - return await testSubjects.click('newItemButton'); - } - const createNewItemPromptExists = await this.getCreateDashboardPromptExists(); - if (createNewItemPromptExists) { - return await this.clickCreateDashboardPrompt(); - } - - throw new Error( - 'Page is still loading... waiting for create new prompt or button to appear' - ); - }); + public async clickNewDashboard() { + await listingTable.clickNewButton('createDashboardPromptButton'); } - async clickCreateDashboardPrompt() { + public async clickCreateDashboardPrompt() { await testSubjects.click('createDashboardPromptButton'); } - async getCreateDashboardPromptExists() { + public async getCreateDashboardPromptExists() { return await testSubjects.exists('createDashboardPromptButton'); } - async checkDashboardListingRow(id) { - await testSubjects.click(`checkboxSelectRow-${id}`); - } - - async checkDashboardListingSelectAllCheckbox() { - const element = await testSubjects.find('checkboxSelectAll'); - const isSelected = await element.isSelected(); - if (!isSelected) { - log.debug(`checking checkbox "checkboxSelectAll"`); - await testSubjects.click('checkboxSelectAll'); - } - } - - async clickDeleteSelectedDashboards() { - await testSubjects.click('deleteSelectedItems'); - } - - async isOptionsOpen() { + public async isOptionsOpen() { log.debug('isOptionsOpen'); return await testSubjects.exists('dashboardOptionsMenu'); } - async openOptions() { + public async openOptions() { log.debug('openOptions'); const isOpen = await this.isOptionsOpen(); if (!isOpen) { @@ -272,36 +234,36 @@ export function DashboardPageProvider({ getService, getPageObjects }) { } // avoids any 'Object with id x not found' errors when switching tests. - async clearSavedObjectsFromAppLinks() { + public async clearSavedObjectsFromAppLinks() { await PageObjects.header.clickVisualize(); await PageObjects.visualize.gotoLandingPage(); await PageObjects.header.clickDashboard(); await this.gotoDashboardLandingPage(); } - async isMarginsOn() { + public async isMarginsOn() { log.debug('isMarginsOn'); await this.openOptions(); return await testSubjects.getAttribute('dashboardMarginsCheckbox', 'checked'); } - async useMargins(on = true) { + public async useMargins(on = true) { await this.openOptions(); const isMarginsOn = await this.isMarginsOn(); - if (isMarginsOn !== on) { + if (isMarginsOn !== 'on') { return await testSubjects.click('dashboardMarginsCheckbox'); } } - async gotoDashboardEditMode(dashboardName) { + public async gotoDashboardEditMode(dashboardName: string) { await this.loadSavedDashboard(dashboardName); await this.switchToEditMode(); } - async renameDashboard(dashName) { - log.debug(`Naming dashboard ` + dashName); + public async renameDashboard(dashboardName: string) { + log.debug(`Naming dashboard ` + dashboardName); await testSubjects.click('dashboardRenameButton'); - await testSubjects.setValue('savedObjectTitle', dashName); + await testSubjects.setValue('savedObjectTitle', dashboardName); } /** @@ -309,11 +271,14 @@ export function DashboardPageProvider({ getService, getPageObjects }) { * verify that the save was successful, close the toast and return the * toast message * - * @param dashName {String} + * @param dashboardName {String} * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, needsConfirm: false, waitDialogIsClosed: boolean }} */ - async saveDashboard(dashName, saveOptions = { waitDialogIsClosed: true }) { - await this.enterDashboardTitleAndClickSave(dashName, saveOptions); + public async saveDashboard( + dashboardName: string, + saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true } + ) { + await this.enterDashboardTitleAndClickSave(dashboardName, saveOptions); if (saveOptions.needsConfirm) { await this.clickSave(); @@ -328,37 +293,24 @@ export function DashboardPageProvider({ getService, getPageObjects }) { return message; } - async deleteDashboard(dashboardName, dashboardId) { - await this.gotoDashboardLandingPage(); - await this.searchForDashboardWithName(dashboardName); - await this.checkDashboardListingRow(dashboardId); - await this.clickDeleteSelectedDashboards(); - await PageObjects.common.clickConfirmOnModal(); - } - - async cancelSave() { + public async cancelSave() { log.debug('Canceling save'); await testSubjects.click('saveCancelButton'); } - async clickSave() { + public async clickSave() { log.debug('DashboardPage.clickSave'); await testSubjects.click('confirmSaveSavedObjectButton'); } - async pressEnterKey() { - log.debug('DashboardPage.pressEnterKey'); - await PageObjects.common.pressEnterKey(); - } - /** * * @param dashboardTitle {String} * @param saveOptions {{storeTimeWithDashboard: boolean, saveAsNew: boolean, waitDialogIsClosed: boolean}} */ - async enterDashboardTitleAndClickSave( - dashboardTitle, - saveOptions = { waitDialogIsClosed: true } + public async enterDashboardTitleAndClickSave( + dashboardTitle: string, + saveOptions: SaveDashboardOptions = { waitDialogIsClosed: true } ) { await testSubjects.click('dashboardSaveMenuItem'); const modalDialog = await testSubjects.find('savedObjectSaveModal'); @@ -380,128 +332,66 @@ export function DashboardPageProvider({ getService, getPageObjects }) { } } - async ensureDuplicateTitleCallout() { + public async ensureDuplicateTitleCallout() { await testSubjects.existOrFail('titleDupicateWarnMsg'); } /** * @param dashboardTitle {String} */ - async enterDashboardTitleAndPressEnter(dashboardTitle) { + public async enterDashboardTitleAndPressEnter(dashboardTitle: string) { await testSubjects.click('dashboardSaveMenuItem'); const modalDialog = await testSubjects.find('savedObjectSaveModal'); log.debug('entering new title'); await testSubjects.setValue('savedObjectTitle', dashboardTitle); - await this.pressEnterKey(); + await PageObjects.common.pressEnterKey(); await testSubjects.waitForDeleted(modalDialog); } - async selectDashboard(dashName) { - await testSubjects.click(`dashboardListingTitleLink-${dashName.split(' ').join('-')}`); - } - - async clearSearchValue() { - log.debug(`clearSearchValue`); - - await this.gotoDashboardLandingPage(); - - await retry.try(async () => { - const searchFilter = await this.getSearchFilter(); - await searchFilter.clearValue(); - await PageObjects.common.pressEnterKey(); - }); - } - - async getSearchFilterValue() { - const searchFilter = await this.getSearchFilter(); - return await searchFilter.getAttribute('value'); - } - - async getSearchFilter() { - const searchFilter = await find.allByCssSelector('.euiFieldSearch'); - return searchFilter[0]; - } - - async searchForDashboardWithName(dashName) { - log.debug(`searchForDashboardWithName: ${dashName}`); - - await this.gotoDashboardLandingPage(); - - await retry.try(async () => { - const searchFilter = await this.getSearchFilter(); - await searchFilter.clearValue(); - await searchFilter.click(); - // Note: this replacement of - to space is to preserve original logic but I'm not sure why or if it's needed. - await searchFilter.type(dashName.replace('-', ' ')); - await PageObjects.common.pressEnterKey(); - await find.waitForDeletedByCssSelector('.euiBasicTable-loading', 5000); - }); - - await PageObjects.header.waitUntilLoadingHasFinished(); - } - - async getCountOfDashboardsInListingTable() { - const dashboardTitles = await find.allByCssSelector( - '[data-test-subj^="dashboardListingTitleLink"]' - ); - return dashboardTitles.length; - } - - async getDashboardCountWithName(dashName) { - log.debug(`getDashboardCountWithName: ${dashName}`); - - await this.searchForDashboardWithName(dashName); - const links = await testSubjects.findAll( - `dashboardListingTitleLink-${dashName.replace(/ /g, '-')}` - ); - return links.length; - } - // use the search filter box to narrow the results down to a single // entry, or at least to a single page of results - async loadSavedDashboard(dashName) { - log.debug(`Load Saved Dashboard ${dashName}`); + public async loadSavedDashboard(dashboardName: string) { + log.debug(`Load Saved Dashboard ${dashboardName}`); await this.gotoDashboardLandingPage(); - await this.searchForDashboardWithName(dashName); + await listingTable.searchForItemWithName(dashboardName); await retry.try(async () => { - await this.selectDashboard(dashName); + await listingTable.clickItemLink('dashboard', dashboardName); await PageObjects.header.waitUntilLoadingHasFinished(); // check Dashboard landing page is not present await testSubjects.missingOrFail('dashboardLandingPage', { timeout: 10000 }); }); } - async getPanelTitles() { + public async getPanelTitles() { log.debug('in getPanelTitles'); const titleObjects = await testSubjects.findAll('dashboardPanelTitle'); return await Promise.all(titleObjects.map(async title => await title.getVisibleText())); } - async getPanelDimensions() { + public async getPanelDimensions() { const panels = await find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes - async function getPanelDimensions(panel) { - const size = await panel.getSize(); - return { - width: size.width, - height: size.height, - }; - } - - const getDimensionsPromises = _.map(panels, getPanelDimensions); - return await Promise.all(getDimensionsPromises); + return await Promise.all( + panels.map(async panel => { + const size = await panel.getSize(); + return { + width: size.width, + height: size.height, + }; + }) + ); } - async getPanelCount() { + public async getPanelCount() { log.debug('getPanelCount'); const panels = await testSubjects.findAll('embeddablePanel'); return panels.length; } - getTestVisualizations() { + public getTestVisualizations() { return [ { name: PIE_CHART_VIS_NAME, description: 'PieChart' }, { name: 'Visualization☺ VerticalBarChart', description: 'VerticalBarChart' }, @@ -513,69 +403,45 @@ export function DashboardPageProvider({ getService, getPageObjects }) { ]; } - getTestVisualizationNames() { + public getTestVisualizationNames() { return this.getTestVisualizations().map(visualization => visualization.name); } - getTestVisualizationDescriptions() { + public getTestVisualizationDescriptions() { return this.getTestVisualizations().map(visualization => visualization.description); } - async getDashboardPanels() { + public async getDashboardPanels() { return await testSubjects.findAll('embeddablePanel'); } - async addVisualizations(visualizations) { + public async addVisualizations(visualizations: string[]) { await dashboardAddPanel.addVisualizations(visualizations); } - async setTimepickerInHistoricalDataRange() { - await PageObjects.timePicker.setDefaultAbsoluteRange(); - } - - async setTimepickerInDataRange() { - const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - } - - async setTimepickerInLogstashDataRange() { - const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - } - - async setSaveAsNewCheckBox(checked) { + public async setSaveAsNewCheckBox(checked: boolean) { log.debug('saveAsNewCheckbox: ' + checked); - const saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); + let saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); const isAlreadyChecked = (await saveAsNewCheckbox.getAttribute('aria-checked')) === 'true'; if (isAlreadyChecked !== checked) { log.debug('Flipping save as new checkbox'); - const saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); + saveAsNewCheckbox = await testSubjects.find('saveAsNewCheckbox'); await retry.try(() => saveAsNewCheckbox.click()); } } - async setStoreTimeWithDashboard(checked) { + public async setStoreTimeWithDashboard(checked: boolean) { log.debug('Storing time with dashboard: ' + checked); - const storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); + let storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); const isAlreadyChecked = (await storeTimeCheckbox.getAttribute('aria-checked')) === 'true'; if (isAlreadyChecked !== checked) { log.debug('Flipping store time checkbox'); - const storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); + storeTimeCheckbox = await testSubjects.find('storeTimeWithDashboard'); await retry.try(() => storeTimeCheckbox.click()); } } - async getFilterDescriptions(timeout = defaultFindTimeout) { - const filters = await find.allByCssSelector( - '.filter-bar > .filter > .filter-description', - timeout - ); - return _.map(filters, async filter => await filter.getVisibleText()); - } - - async getSharedItemsCount() { + public async getSharedItemsCount() { log.debug('in getSharedItemsCount'); const attributeName = 'data-shared-items-count'; const element = await find.byCssSelector(`[${attributeName}]`); @@ -586,13 +452,14 @@ export function DashboardPageProvider({ getService, getPageObjects }) { throw new Error('no element'); } - async waitForRenderComplete() { + public async waitForRenderComplete() { log.debug('waitForRenderComplete'); const count = await this.getSharedItemsCount(); + // eslint-disable-next-line radix await renderable.waitForRender(parseInt(count)); } - async getSharedContainerData() { + public async getSharedContainerData() { log.debug('getSharedContainerData'); const sharedContainer = await find.byCssSelector('[data-shared-items-container]'); return { @@ -602,7 +469,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) { }; } - async getPanelSharedItemData() { + public async getPanelSharedItemData() { log.debug('in getPanelSharedItemData'); const sharedItems = await find.allByCssSelector('[data-shared-item]'); return await Promise.all( @@ -615,17 +482,17 @@ export function DashboardPageProvider({ getService, getPageObjects }) { ); } - async checkHideTitle() { + public async checkHideTitle() { log.debug('ensure that you can click on hide title checkbox'); await this.openOptions(); return await testSubjects.click('dashboardPanelTitlesCheckbox'); } - async expectMissingSaveOption() { + public async expectMissingSaveOption() { await testSubjects.missingOrFail('dashboardSaveMenuItem'); } - async getNotLoadedVisualizations(vizList) { + public async getNotLoadedVisualizations(vizList: string[]) { const checkList = []; for (const name of vizList) { const isPresent = await testSubjects.exists( diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 3ba0f217813f2..85d8cff675f2d 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -63,6 +63,18 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { }); } + async inputSavedSearchTitle(searchName) { + await testSubjects.setValue('savedObjectTitle', searchName); + } + + async clickConfirmSavedSearch() { + await testSubjects.click('confirmSaveSavedObjectButton'); + } + + async openAddFilterPanel() { + await testSubjects.click('addFilter'); + } + async waitUntilSearchingHasFinished() { const spinner = await testSubjects.find('loadingSpinner'); await find.waitForElementHidden(spinner, defaultFindTimeout * 10); @@ -117,6 +129,10 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { await testSubjects.click('discoverOpenButton'); } + async closeLoadSavedSearchPanel() { + await testSubjects.click('euiFlyoutCloseButton'); + } + async getChartCanvas() { return await find.byCssSelector('.echChart canvas:last-of-type'); } diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index cf9eb4332c3e1..a641fbda023c3 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -22,7 +22,6 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function HomePageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); - const find = getService('find'); class HomePage { async clickSynopsis(title: string) { @@ -38,12 +37,15 @@ export function HomePageProvider({ getService }: FtrProviderContext) { } async isSampleDataSetInstalled(id: string) { - return await testSubjects.exists(`removeSampleDataSet${id}`); + return !(await testSubjects.exists(`addSampleDataSet${id}`)); } async addSampleDataSet(id: string) { - await testSubjects.click(`addSampleDataSet${id}`); - await this._waitForSampleDataLoadingAction(id); + const isInstalled = await this.isSampleDataSetInstalled(id); + if (!isInstalled) { + await testSubjects.click(`addSampleDataSet${id}`); + await this._waitForSampleDataLoadingAction(id); + } } async removeSampleDataSet(id: string) { @@ -62,13 +64,8 @@ export function HomePageProvider({ getService }: FtrProviderContext) { } async launchSampleDataSet(id: string) { - if (await find.existsByCssSelector(`#sampleDataLinks${id}`)) { - // omits cloud test failures - await find.clickByCssSelectorWhenNotDisabled(`#sampleDataLinks${id}`); - await find.clickByCssSelector('.euiContextMenuItem:nth-of-type(1)'); - } else { - await testSubjects.click(`launchSampleDataSet${id}`); - } + await this.addSampleDataSet(id); + await testSubjects.click(`launchSampleDataSet${id}`); } async loadSavedObjects() { diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 5526243ea2bbd..4ba8ddb035913 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -22,7 +22,6 @@ import { CommonPageProvider } from './common_page'; import { ConsolePageProvider } from './console_page'; // @ts-ignore not TS yet import { ContextPageProvider } from './context_page'; -// @ts-ignore not TS yet import { DashboardPageProvider } from './dashboard_page'; // @ts-ignore not TS yet import { DiscoverPageProvider } from './discover_page'; diff --git a/test/functional/page_objects/time_picker.js b/test/functional/page_objects/time_picker.js index 8717517f44864..7c67678429478 100644 --- a/test/functional/page_objects/time_picker.js +++ b/test/functional/page_objects/time_picker.js @@ -264,6 +264,22 @@ export function TimePickerPageProvider({ getService, getPageObjects }) { await this.closeQuickSelectTimeMenu(); } + + async setHistoricalDataRange() { + await this.setDefaultAbsoluteRange(); + } + + async setDefaultDataRange() { + const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } + + async setLogstashDataRange() { + const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } } return new TimePickerPage(); diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 138e5758ede7c..0f14489a39dbc 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -204,8 +204,7 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr public async filterLegend(name: string) { await this.toggleLegend(); await testSubjects.click(`legend-${name}`); - const filters = await testSubjects.find(`legend-${name}-filters`); - const [filterIn] = await filters.findAllByCssSelector(`input`); + const filterIn = await testSubjects.find(`legend-${name}-filterIn`); await filterIn.click(); await this.waitForVisualizationRenderingStabilized(); } diff --git a/test/functional/page_objects/visualize_editor_page.ts b/test/functional/page_objects/visualize_editor_page.ts index 7e512975356f3..30e13d551fa28 100644 --- a/test/functional/page_objects/visualize_editor_page.ts +++ b/test/functional/page_objects/visualize_editor_page.ts @@ -97,8 +97,9 @@ export function VisualizeEditorPageProvider({ getService, getPageObjects }: FtrP } public async clickSplitDirection(direction: string) { - const control = await testSubjects.find('visEditorSplitBy'); - const radioBtn = await control.findByCssSelector(`[title="${direction}"]`); + const radioBtn = await find.byCssSelector( + `[data-test-subj="visEditorSplitBy"][title="${direction}"]` + ); await radioBtn.click(); } diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 1562cf9745f2d..0071b8d993f70 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -44,15 +44,7 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide } public async clickNewVisualization() { - // newItemButton button is only visible when there are items in the listing table is displayed. - let exists = await testSubjects.exists('newItemButton'); - if (exists) { - return await testSubjects.click('newItemButton'); - } - - exists = await testSubjects.exists('createVisualizationPromptButton'); - // no viz exist, click createVisualizationPromptButton to create new dashboard - return await this.createVisualizationPromptButton(); + await listingTable.clickNewButton('createVisualizationPromptButton'); } public async createVisualizationPromptButton() { @@ -322,6 +314,10 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide async () => (await globalNav.getLastBreadcrumb()) === vizName ); } + + public async clickLensWidget() { + await this.clickVisType('lens'); + } } return new VisualizePage(); diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts index a4cd98b2a06ec..fe17532f6a41a 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -25,7 +25,7 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return new (class AppsMenu { /** - * Get the text and href from each of the links in the apps menu + * Get the attributes from each of the links in the apps menu */ public async readLinks() { const appMenu = await testSubjects.find('navDrawer'); @@ -37,12 +37,21 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return { text: $(link).text(), href: $(link).attr('href'), + disabled: $(link).attr('disabled') != null, }; }); return links; } + /** + * Get the attributes from the link with the given name. + * @param name + */ + public async getLink(name: string) { + return (await this.readLinks()).find(nl => nl.text === name); + } + /** * Determine if an app link with the given name exists * @param name diff --git a/test/functional/services/dashboard/visualizations.js b/test/functional/services/dashboard/visualizations.js index 5e722ccce8970..f7a6fb7d2f694 100644 --- a/test/functional/services/dashboard/visualizations.js +++ b/test/functional/services/dashboard/visualizations.js @@ -24,7 +24,14 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }) { const queryBar = getService('queryBar'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); - const PageObjects = getPageObjects(['dashboard', 'visualize', 'visEditor', 'header', 'discover']); + const PageObjects = getPageObjects([ + 'dashboard', + 'visualize', + 'visEditor', + 'header', + 'discover', + 'timePicker', + ]); return new (class DashboardVisualizations { async createAndAddTSVBVisualization(name) { @@ -43,7 +50,7 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }) { log.debug(`createSavedSearch(${name})`); await PageObjects.header.clickDiscover(); - await PageObjects.dashboard.setTimepickerInHistoricalDataRange(); + await PageObjects.timePicker.setHistoricalDataRange(); if (query) { await queryBar.setQuery(query); diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts index ec886cf694f2e..c7667ae7b4049 100644 --- a/test/functional/services/listing_table.ts +++ b/test/functional/services/listing_table.ts @@ -25,20 +25,35 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider const log = getService('log'); const retry = getService('retry'); const { common, header } = getPageObjects(['common', 'header']); + const prefixMap = { visualize: 'vis', dashboard: 'dashboard' }; + /** + * This class provides functions for dashboard and visualize landing pages + */ class ListingTable { - public async getSearchFilter() { + private async getSearchFilter() { const searchFilter = await find.allByCssSelector('.euiFieldSearch'); return searchFilter[0]; } - public async clearFilter() { + /** + * Returns search input value on landing page + */ + public async getSearchFilterValue() { + const searchFilter = await this.getSearchFilter(); + return await searchFilter.getAttribute('value'); + } + + /** + * Clears search input on landing page + */ + public async clearSearchFilter() { const searchFilter = await this.getSearchFilter(); await searchFilter.clearValue(); await searchFilter.click(); } - public async getAllVisualizationNamesOnCurrentPage(): Promise { + private async getAllItemsNamesOnCurrentPage(): Promise { const visualizationNames = []; const links = await find.allByCssSelector('.kuiLink'); for (let i = 0; i < links.length; i++) { @@ -48,14 +63,39 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider return visualizationNames; } + /** + * Navigates through all pages on Landing page and returns array of items names + */ + public async getAllItemsNames(): Promise { + log.debug('ListingTable.getAllItemsNames'); + let morePages = true; + let visualizationNames: string[] = []; + while (morePages) { + visualizationNames = visualizationNames.concat(await this.getAllItemsNamesOnCurrentPage()); + morePages = !((await testSubjects.getAttribute('pagerNextButton', 'disabled')) === 'true'); + if (morePages) { + await testSubjects.click('pagerNextButton'); + await header.waitUntilLoadingHasFinished(); + } + } + return visualizationNames; + } + + /** + * Returns items count on landing page + * @param appName 'visualize' | 'dashboard' + */ public async getItemsCount(appName: 'visualize' | 'dashboard'): Promise { - const prefixMap = { visualize: 'vis', dashboard: 'dashboard' }; const elements = await find.allByCssSelector( `[data-test-subj^="${prefixMap[appName]}ListingTitleLink"]` ); return elements.length; } + /** + * Types name into search field on Landing page and waits till search completed + * @param name item name + */ public async searchForItemWithName(name: string) { log.debug(`searchForItemWithName: ${name}`); @@ -71,10 +111,51 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider await header.waitUntilLoadingHasFinished(); } + /** + * Searches for item on Landing page and retruns items count that match `ListingTitleLink-${name}` pattern + * @param appName 'visualize' | 'dashboard' + * @param name item name + */ + public async searchAndGetItemsCount(appName: 'visualize' | 'dashboard', name: string) { + await this.searchForItemWithName(name); + const links = await testSubjects.findAll( + `${prefixMap[appName]}ListingTitleLink-${name.replace(/ /g, '-')}` + ); + return links.length; + } + public async clickDeleteSelected() { await testSubjects.click('deleteSelectedItems'); } + public async clickItemCheckbox(id: string) { + await testSubjects.click(`checkboxSelectRow-${id}`); + } + + /** + * Searches for item by name, selects checbox and deletes it + * @param name item name + * @param id row id + */ + public async deleteItem(name: string, id: string) { + await this.searchForItemWithName(name); + await this.clickItemCheckbox(id); + await this.clickDeleteSelected(); + await common.clickConfirmOnModal(); + } + + /** + * Clicks item on Landing page by link name if it is present + * @param appName 'dashboard' | 'visualize' + * @param name item name + */ + public async clickItemLink(appName: 'dashboard' | 'visualize', name: string) { + await testSubjects.click(`${appName}ListingTitleLink-${name.split(' ').join('-')}`); + } + + /** + * Checks 'SelectAll' checkbox on + */ public async checkListingSelectAllCheckbox() { const element = await testSubjects.find('checkboxSelectAll'); const isSelected = await element.isSelected(); @@ -84,21 +165,20 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider } } - public async getAllVisualizationNames(): Promise { - log.debug('ListingTable.getAllVisualizationNames'); - let morePages = true; - let visualizationNames: string[] = []; - while (morePages) { - visualizationNames = visualizationNames.concat( - await this.getAllVisualizationNamesOnCurrentPage() - ); - morePages = !((await testSubjects.getAttribute('pagerNextButton', 'disabled')) === 'true'); - if (morePages) { - await testSubjects.click('pagerNextButton'); - await header.waitUntilLoadingHasFinished(); + /** + * Clicks NewItem button on Landing page + * @param promptBtnTestSubj testSubj locator for Prompt button + */ + public async clickNewButton(promptBtnTestSubj: string): Promise { + await retry.try(async () => { + // newItemButton button is only visible when there are items in the listing table is displayed. + if (await testSubjects.exists('newItemButton')) { + await testSubjects.click('newItemButton'); + } else { + // no items exist, click createPromptButton to create new dashboard/visualization + await testSubjects.click(promptBtnTestSubj); } - } - return visualizationNames; + }); } } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index 02c507dbb3ed8..1eac93c8538e4 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -7,7 +7,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "17.3.1", + "@elastic/eui": "18.0.0", "react": "^16.12.0", "react-dom": "^16.12.0" } diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json new file mode 100644 index 0000000000000..91d8e6fd8f9e1 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_app_status", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_app_status"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_app_status/package.json b/test/plugin_functional/plugins/core_app_status/package.json new file mode 100644 index 0000000000000..61655487c6acb --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_app_status", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_app_status", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_app_status/public/application.tsx b/test/plugin_functional/plugins/core_app_status/public/application.tsx new file mode 100644 index 0000000000000..323774392a6d7 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/application.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const AppStatusApp = () => ( + + + + + +

Welcome to App Status Test App!

+
+
+
+ + + + +

App Status Test App home page section title

+
+
+
+ App Status Test App content +
+
+
+); + +export const renderApp = (context: AppMountContext, { element }: AppMountParameters) => { + render(, element); + + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_app_status/public/index.ts b/test/plugin_functional/plugins/core_app_status/public/index.ts new file mode 100644 index 0000000000000..e0ad7c25a54b8 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreAppStatusPlugin, CoreAppStatusPluginSetup, CoreAppStatusPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppStatusPlugin(); diff --git a/test/plugin_functional/plugins/core_app_status/public/plugin.tsx b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx new file mode 100644 index 0000000000000..85caaaf5f9090 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup, AppUpdater, AppUpdatableFields, CoreStart } from 'kibana/public'; +import { BehaviorSubject } from 'rxjs'; + +export class CoreAppStatusPlugin + implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'app_status', + title: 'App Status', + euiIconType: 'snowflake', + updater$: this.appUpdater, + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return { + setAppStatus: (status: Partial) => { + this.appUpdater.next(() => status); + }, + navigateToApp: async (appId: string) => { + return core.application.navigateToApp(appId); + }, + }; + } + public stop() {} +} + +export type CoreAppStatusPluginSetup = ReturnType; +export type CoreAppStatusPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json new file mode 100644 index 0000000000000..5fcaeafbb0d85 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index 67ad28c083dbc..1bfb1e8ba4bca 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -7,7 +7,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "17.3.1", + "@elastic/eui": "18.0.0", "react": "^16.12.0" } } diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json index b22a1ff2d4176..6d6b04fba889c 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "17.3.1", + "@elastic/eui": "18.0.0", "react": "^16.12.0" }, "scripts": { diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json index 8c91826d7b450..964adacb2ac09 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/package.json @@ -8,7 +8,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "17.3.1", + "@elastic/eui": "18.0.0", "react": "^16.12.0" }, "scripts": { diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts new file mode 100644 index 0000000000000..703ae30533bae --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, +} from '../../../../src/core/public/application/types'; +import { PluginFunctionalProviderContext } from '../../services'; +import { CoreAppStatusPluginStart } from '../../plugins/core_app_status/public/plugin'; +import '../../plugins/core_provider_plugin/types'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + + const setAppStatus = async (s: Partial) => { + await browser.executeAsync(async (status: Partial, cb: Function) => { + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + plugin.setAppStatus(status); + cb(); + }, s); + }; + + const navigateToApp = async (i: string): Promise<{ error?: string }> => { + return (await browser.executeAsync(async (appId, cb: Function) => { + // navigating in legacy mode performs a page refresh + // and webdriver seems to re-execute the script after the reload + // as it considers it didn't end on the previous session. + // however when testing navigation to NP app, __coreProvider is not accessible + // so we need to check for existence. + if (!window.__coreProvider) { + cb({}); + } + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + try { + await plugin.navigateToApp(appId); + cb({}); + } catch (e) { + cb({ + error: e.message, + }); + } + }, i)) as any; + }; + + describe('application status management', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('settings'); + }); + + it('can change the navLink status at runtime', async () => { + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.disabled, + }); + let link = await appsMenu.getLink('App Status'); + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(true); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.hidden, + }); + link = await appsMenu.getLink('App Status'); + expect(link).to.eql(undefined); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.visible, + tooltip: 'Some tooltip', + }); + link = await appsMenu.getLink('Some tooltip'); // the tooltip replaces the name in the selector we use. + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(false); + }); + + it('shows an error when navigating to an inaccessible app', async () => { + await setAppStatus({ + status: AppStatus.inaccessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.contain( + 'Trying to navigate to an inaccessible application: app_status' + ); + }); + + it('allows to navigate to an accessible app', async () => { + await setAppStatus({ + status: AppStatus.accessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.eql(undefined); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index a3c9d9d63e353..231458fad155b 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -27,12 +27,18 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider const browser = getService('browser'); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); const loadingScreenShown = () => testSubjects.existOrFail('kbnLoadingMessage'); + const getAppWrapperWidth = async () => { + const wrapper = await find.byClassName('app-wrapper'); + return (await wrapper.getSize()).width; + }; + const getKibanaUrl = (pathname?: string, search?: string) => url.format({ protocol: 'http:', @@ -99,12 +105,20 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider await PageObjects.common.navigateToApp('chromeless'); await loadingScreenNotShown(); expect(await testSubjects.exists('headerGlobalNav')).to.be(false); + + const wrapperWidth = await getAppWrapperWidth(); + const windowWidth = (await browser.getWindowSize()).width; + expect(wrapperWidth).to.eql(windowWidth); }); it('navigating away from chromeless application shows chrome', async () => { await PageObjects.common.navigateToApp('foo'); await loadingScreenNotShown(); expect(await testSubjects.exists('headerGlobalNav')).to.be(true); + + const wrapperWidth = await getAppWrapperWidth(); + const windowWidth = (await browser.getWindowSize()).width; + expect(wrapperWidth).to.be.below(windowWidth); }); it.skip('can navigate from NP apps to legacy apps', async () => { diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 6c55245d10f03..d66e2e7dc5da7 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -28,5 +28,6 @@ export default function({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./ui_settings')); loadTestFile(require.resolve('./top_nav')); loadTestFile(require.resolve('./application_leave_confirm')); + loadTestFile(require.resolve('./application_status')); }); } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 7e86d2f1dc435..71e3bdd6c8c84 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -4,6 +4,7 @@ "xpack.actions": "legacy/plugins/actions", "xpack.advancedUiActions": "plugins/advanced_ui_actions", "xpack.alerting": "legacy/plugins/alerting", + "xpack.triggersActionsUI": "legacy/plugins/triggers_actions_ui", "xpack.apm": "legacy/plugins/apm", "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", diff --git a/x-pack/index.js b/x-pack/index.js index 56547f89b1e90..83a7b5540334f 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -42,6 +42,7 @@ import { transform } from './legacy/plugins/transform'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; import { lens } from './legacy/plugins/lens'; +import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui'; module.exports = function(kibana) { return [ @@ -83,5 +84,6 @@ module.exports = function(kibana) { snapshotRestore(kibana), actions(kibana), alerting(kibana), + triggersActionsUI(kibana), ]; }; diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts index 2f15ae1c0a2b3..63f1b545179c7 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { ActionTypeRegistry } from './action_type_registry'; import { ExecutorType } from './types'; import { ActionExecutor, ExecutorError, TaskRunnerFactory } from './lib'; import { configUtilsMock } from './actions_config.mock'; -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.setup(); const actionTypeRegistryParams = { taskManager: mockTaskManager, taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor()), diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.ts index f66d1947c2b8b..351c1add7b451 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.ts @@ -6,11 +6,11 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { TaskManagerSetupContract } from './shim'; -import { RunContext } from '../../task_manager/server'; +import { RunContext, TaskManagerSetupContract } from '../../../../plugins/task_manager/server'; import { ExecutorError, TaskRunnerFactory } from './lib'; import { ActionType } from './types'; import { ActionsConfigurationUtilities } from './actions_config'; + interface ConstructorOptions { taskManager: TaskManagerSetupContract; taskRunnerFactory: TaskRunnerFactory; diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts index 9e75248c56cae..dfbd2db4b6842 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.test.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -10,7 +10,7 @@ import { ActionTypeRegistry } from './action_type_registry'; import { ActionsClient } from './actions_client'; import { ExecutorType } from './types'; import { ActionExecutor, TaskRunnerFactory } from './lib'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { configUtilsMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; @@ -23,7 +23,7 @@ const defaultKibanaIndex = '.kibana'; const savedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.setup(); const actionTypeRegistryParams = { taskManager: mockTaskManager, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 4aaecc8e9d7df..74263c603c11e 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -49,7 +49,7 @@ beforeEach(() => { describe('actionTypeRegistry.get() works', () => { test('action type static data is as expected', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('email'); + expect(actionType.name).toEqual('Email'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index dd2bd328ce53f..94d7852e76fad 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -118,7 +118,9 @@ export function getActionType(params: GetActionTypeParams): ActionType { const { logger, configurationUtilities } = params; return { id: '.email', - name: 'email', + name: i18n.translate('xpack.actions.builtin.emailTitle', { + defaultMessage: 'Email', + }), validate: { config: schema.object(ConfigSchemaProps, { validate: curry(validateConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 1da8b06e1587a..dbac84ef681f1 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { describe('actionTypeRegistry.get() works', () => { test('action type static data is as expected', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('index'); + expect(actionType.name).toEqual('Index'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts index 0e9fe0483ee1e..ddf33ba63f71a 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts @@ -38,7 +38,9 @@ const ParamsSchema = schema.object({ export function getActionType({ logger }: { logger: Logger }): ActionType { return { id: '.index', - name: 'index', + name: i18n.translate('xpack.actions.builtin.esIndexTitle', { + defaultMessage: 'Index', + }), validate: { config: ConfigSchema, params: ParamsSchema, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts index 3a0c9f415cc2b..5fcf39c2e8fdd 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts @@ -6,7 +6,7 @@ import { ActionExecutor, TaskRunnerFactory } from '../lib'; import { ActionTypeRegistry } from '../action_type_registry'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../../plugins/task_manager/server/task_manager.mock'; import { registerBuiltInActionTypes } from './index'; import { Logger } from '../../../../../../src/core/server'; import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; @@ -20,7 +20,7 @@ export function createActionTypeRegistry(): { } { const logger = loggingServiceMock.create().get() as jest.Mocked; const actionTypeRegistry = new ActionTypeRegistry({ - taskManager: taskManagerMock.create(), + taskManager: taskManagerMock.setup(), taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor()), actionsConfigUtils: configUtilsMock, }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts index cb3548524ebbb..f60fdf7fef95e 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -38,7 +38,7 @@ beforeAll(() => { describe('get()', () => { test('should return correct action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('pagerduty'); + expect(actionType.name).toEqual('PagerDuty'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts index 250c169278c57..b26621702cf5b 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -96,7 +96,9 @@ export function getActionType({ }): ActionType { return { id: '.pagerduty', - name: 'pagerduty', + name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', { + defaultMessage: 'PagerDuty', + }), validate: { config: schema.object(configSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index c59ddf97017fd..8f28b9e8f5125 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { describe('get()', () => { test('returns action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('server-log'); + expect(actionType.name).toEqual('Server log'); }); }); @@ -98,6 +98,6 @@ describe('execute()', () => { config: {}, secrets: {}, }); - expect(mockedLogger.info).toHaveBeenCalledWith('server-log: message text here'); + expect(mockedLogger.info).toHaveBeenCalledWith('Server log: message text here'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index 0edf409e4d46c..34b8602eeba36 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -12,7 +12,7 @@ import { Logger } from '../../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; import { withoutControlCharacters } from './lib/string_utils'; -const ACTION_NAME = 'server-log'; +const ACTION_NAME = 'Server log'; // params definition diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts index a2b0db8bdb70f..aebc9c4993599 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts @@ -29,7 +29,7 @@ beforeAll(() => { describe('action registeration', () => { test('returns action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('slack'); + expect(actionType.name).toEqual('Slack'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index 92611d6f162ff..b8989e59a2257 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -49,7 +49,9 @@ export function getActionType({ }): ActionType { return { id: '.slack', - name: 'slack', + name: i18n.translate('xpack.actions.builtin.slackTitle', { + defaultMessage: 'Slack', + }), validate: { secrets: schema.object(secretsSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts index 64dd3a485f8e2..b95fef97ac7b9 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { describe('actionType', () => { test('exposes the action as `webhook` on its Id and Name', () => { expect(actionType.id).toEqual('.webhook'); - expect(actionType.name).toEqual('webhook'); + expect(actionType.name).toEqual('Webhook'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts index 06fe2fb0e591c..fa88d3c72c163 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts @@ -56,7 +56,9 @@ export function getActionType({ }): ActionType { return { id: '.webhook', - name: 'webhook', + name: i18n.translate('xpack.actions.builtin.webhookTitle', { + defaultMessage: 'Webhook', + }), validate: { config: schema.object(configSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts index 6de446ee2da76..7dbcfce5ee335 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { createExecuteFunction } from './create_execute_function'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.start(); const savedObjectsClient = savedObjectsClientMock.create(); const getBasePath = jest.fn(); diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.ts index 8ff12b8c3fa4b..ddd8b1df2327b 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsClientContract } from 'src/core/server'; -import { TaskManagerStartContract } from './shim'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; import { GetBasePathFunction } from './types'; interface CreateExecuteFunctionOptions { diff --git a/x-pack/legacy/plugins/actions/server/init.ts b/x-pack/legacy/plugins/actions/server/init.ts index 5eab3418467bc..6f221b08c4bc5 100644 --- a/x-pack/legacy/plugins/actions/server/init.ts +++ b/x-pack/legacy/plugins/actions/server/init.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Legacy } from 'kibana'; import { Plugin } from './plugin'; -import { shim, Server } from './shim'; +import { shim } from './shim'; import { ActionsPlugin } from './types'; -export async function init(server: Server) { +export async function init(server: Legacy.Server) { const { initializerContext, coreSetup, coreStart, pluginsSetup, pluginsStart } = shim(server); const plugin = new Plugin(initializerContext); diff --git a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts index 5b60696c42d52..ad2b74da0d7d4 100644 --- a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { ExecutorError } from './executor_error'; import { ActionExecutor } from './action_executor'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerFactory } from './task_runner_factory'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { actionExecutorMock } from './action_executor.mock'; diff --git a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts index ca6a726f40e14..2dc3d1161399e 100644 --- a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts @@ -6,7 +6,7 @@ import { ActionExecutorContract } from './action_executor'; import { ExecutorError } from './executor_error'; -import { RunContext } from '../../../task_manager/server'; +import { RunContext } from '../../../../../plugins/task_manager/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; import { ActionTaskParams, GetBasePathFunction, SpaceIdToNamespaceFunction } from '../types'; diff --git a/x-pack/legacy/plugins/actions/server/plugin.ts b/x-pack/legacy/plugins/actions/server/plugin.ts index 48f99ba5135b7..ffc4a9cf90e54 100644 --- a/x-pack/legacy/plugins/actions/server/plugin.ts +++ b/x-pack/legacy/plugins/actions/server/plugin.ts @@ -93,7 +93,7 @@ export class Plugin { const actionsConfigUtils = getActionsConfigurationUtilities(config as ActionsConfigType); const actionTypeRegistry = new ActionTypeRegistry({ taskRunnerFactory, - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, actionsConfigUtils, }); this.taskRunnerFactory = taskRunnerFactory; @@ -164,7 +164,7 @@ export class Plugin { }); const executeFn = createExecuteFunction({ - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, getScopedSavedObjectsClient: core.savedObjects.getScopedSavedObjectsClient, getBasePath, }); diff --git a/x-pack/legacy/plugins/actions/server/shim.ts b/x-pack/legacy/plugins/actions/server/shim.ts index f8aa9b8d7a25c..8077dc67c92c4 100644 --- a/x-pack/legacy/plugins/actions/server/shim.ts +++ b/x-pack/legacy/plugins/actions/server/shim.ts @@ -8,7 +8,11 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; import * as Rx from 'rxjs'; import { ActionsConfigType } from './types'; -import { TaskManager } from '../../task_manager/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../plugins/task_manager/server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../../task_manager/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; @@ -24,16 +28,6 @@ import { } from '../../../../../src/core/server'; import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; -// Extend PluginProperties to indicate which plugins are guaranteed to exist -// due to being marked as dependencies -interface Plugins extends Hapi.PluginProperties { - task_manager: TaskManager; -} - -export interface Server extends Legacy.Server { - plugins: Plugins; -} - export interface KibanaConfig { index: string; } @@ -41,14 +35,9 @@ export interface KibanaConfig { /** * Shim what we're thinking setup and start contracts will look like */ -export type TaskManagerStartContract = Pick; export type XPackMainPluginSetupContract = Pick; export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; -export type TaskManagerSetupContract = Pick< - TaskManager, - 'addMiddleware' | 'registerTaskDefinitions' ->; /** * New platform interfaces @@ -74,7 +63,7 @@ export interface ActionsCoreStart { } export interface ActionsPluginsSetup { security?: SecurityPluginSetupContract; - task_manager: TaskManagerSetupContract; + taskManager: TaskManagerSetupContract; xpack_main: XPackMainPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsSetupContract; licensing: LicensingPluginSetup; @@ -83,7 +72,7 @@ export interface ActionsPluginsStart { security?: SecurityPluginStartContract; spaces: () => SpacesPluginStartContract | undefined; encryptedSavedObjects: EncryptedSavedObjectsStartContract; - task_manager: TaskManagerStartContract; + taskManager: TaskManagerStartContract; } /** @@ -92,7 +81,7 @@ export interface ActionsPluginsStart { * @param server Hapi server instance */ export function shim( - server: Server + server: Legacy.Server ): { initializerContext: ActionsPluginInitializerContext; coreSetup: ActionsCoreSetup; @@ -132,7 +121,7 @@ export function shim( const pluginsSetup: ActionsPluginsSetup = { security: newPlatform.setup.plugins.security as SecurityPluginSetupContract | undefined, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerSetup(server)!, xpack_main: server.plugins.xpack_main, encryptedSavedObjects: newPlatform.setup.plugins .encryptedSavedObjects as EncryptedSavedObjectsSetupContract, @@ -146,7 +135,7 @@ export function shim( spaces: () => server.plugins.spaces, encryptedSavedObjects: newPlatform.start.plugins .encryptedSavedObjects as EncryptedSavedObjectsStartContract, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerStart(server)!, }; return { diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index 8e96ad8dae31c..e1a05d6460e25 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -6,10 +6,9 @@ import { TaskRunnerFactory } from './task_runner'; import { AlertTypeRegistry } from './alert_type_registry'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; - -const taskManager = taskManagerMock.create(); +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; +const taskManager = taskManagerMock.setup(); const alertTypeRegistryParams = { taskManager, taskRunnerFactory: new TaskRunnerFactory(), diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index 2003e810a05b5..1e9007202c452 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -6,9 +6,8 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; +import { RunContext, TaskManagerSetupContract } from '../../../../plugins/task_manager/server'; import { TaskRunnerFactory } from './task_runner'; -import { RunContext } from '../../task_manager'; -import { TaskManagerSetupContract } from './shim'; import { AlertType } from './types'; interface ConstructorOptions { diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts index 32293d9755a2a..2af66059d9fed 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -7,14 +7,14 @@ import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; import { AlertsClient } from './alerts_client'; import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { TaskStatus } from '../../task_manager/server'; +import { TaskStatus } from '../../../../plugins/task_manager/server'; import { IntervalSchedule } from './types'; import { resolvable } from './test_utils'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -const taskManager = taskManagerMock.create(); +const taskManager = taskManagerMock.start(); const alertTypeRegistry = alertTypeRegistryMock.create(); const savedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createStart(); diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index 33a6b716e9b8a..fe96a233b8663 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -22,7 +22,6 @@ import { AlertType, IntervalSchedule, } from './types'; -import { TaskManagerStartContract } from './shim'; import { validateAlertTypeParams } from './lib'; import { InvalidateAPIKeyParams, @@ -30,6 +29,7 @@ import { InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, } from '../../../../plugins/security/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts index 519001d07e089..754e02a3f1e5e 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts @@ -7,7 +7,7 @@ import { Request } from 'hapi'; import { AlertsClientFactory, ConstructorOpts } from './alerts_client_factory'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { KibanaRequest } from '../../../../../src/core/server'; import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; @@ -23,7 +23,7 @@ const securityPluginSetup = { }; const alertsClientFactoryParams: jest.Mocked = { logger: loggingServiceMock.create().get(), - taskManager: taskManagerMock.create(), + taskManager: taskManagerMock.start(), alertTypeRegistry: alertTypeRegistryMock.create(), getSpaceId: jest.fn(), spaceIdToNamespace: jest.fn(), diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts index 94a396fbaa806..eab1cc3ce627b 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts @@ -8,10 +8,11 @@ import Hapi from 'hapi'; import uuid from 'uuid'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; -import { SecurityPluginStartContract, TaskManagerStartContract } from './shim'; +import { SecurityPluginStartContract } from './shim'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; import { InvalidateAPIKeyParams } from '../../../../plugins/security/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; export interface ConstructorOpts { logger: Logger; diff --git a/x-pack/legacy/plugins/alerting/server/lib/result_type.ts b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts index 644ae51292249..52843f6362303 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/result_type.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts @@ -15,6 +15,10 @@ export interface Err { } export type Result = Ok | Err; +export type Resultable = { + [P in keyof T]: Result; +}; + export function asOk(value: T): Ok { return { tag: 'ok', @@ -52,3 +56,7 @@ export function map( ): Resolution { return isOk(result) ? onOk(result.value) : onErr(result.error); } + +export function resolveErr(result: Result, onErr: (error: E) => T): T { + return isOk(result) ? result.value : onErr(result.error); +} diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index fb16f579d4c70..357db9e3df97e 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -79,7 +79,7 @@ export class Plugin { }); const alertTypeRegistry = new AlertTypeRegistry({ - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, taskRunnerFactory: this.taskRunnerFactory, }); this.alertTypeRegistry = alertTypeRegistry; @@ -116,7 +116,7 @@ export class Plugin { const alertsClientFactory = new AlertsClientFactory({ alertTypeRegistry: this.alertTypeRegistry!, logger: this.logger, - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, securityPluginSetup: plugins.security, encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects, spaceIdToNamespace, diff --git a/x-pack/legacy/plugins/alerting/server/shim.ts b/x-pack/legacy/plugins/alerting/server/shim.ts index ae29048d83dd9..ccc10f929e123 100644 --- a/x-pack/legacy/plugins/alerting/server/shim.ts +++ b/x-pack/legacy/plugins/alerting/server/shim.ts @@ -7,7 +7,11 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; -import { TaskManager } from '../../task_manager/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../plugins/task_manager/server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../../task_manager/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; import { @@ -31,7 +35,6 @@ import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; // due to being marked as dependencies interface Plugins extends Hapi.PluginProperties { actions: ActionsPlugin; - task_manager: TaskManager; } export interface Server extends Legacy.Server { @@ -41,17 +44,9 @@ export interface Server extends Legacy.Server { /** * Shim what we're thinking setup and start contracts will look like */ -export type TaskManagerStartContract = Pick< - TaskManager, - 'schedule' | 'fetch' | 'remove' | 'runNow' ->; export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; export type XPackMainPluginSetupContract = Pick; -export type TaskManagerSetupContract = Pick< - TaskManager, - 'addMiddleware' | 'registerTaskDefinitions' ->; /** * New platform interfaces @@ -73,7 +68,7 @@ export interface AlertingCoreStart { } export interface AlertingPluginsSetup { security?: SecurityPluginSetupContract; - task_manager: TaskManagerSetupContract; + taskManager: TaskManagerSetupContract; actions: ActionsPluginSetupContract; xpack_main: XPackMainPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsSetupContract; @@ -84,7 +79,7 @@ export interface AlertingPluginsStart { security?: SecurityPluginStartContract; spaces: () => SpacesPluginStartContract | undefined; encryptedSavedObjects: EncryptedSavedObjectsStartContract; - task_manager: TaskManagerStartContract; + taskManager: TaskManagerStartContract; } /** @@ -121,7 +116,7 @@ export function shim( const pluginsSetup: AlertingPluginsSetup = { security: newPlatform.setup.plugins.security as SecurityPluginSetupContract | undefined, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerSetup(server)!, actions: server.plugins.actions.setup, xpack_main: server.plugins.xpack_main, encryptedSavedObjects: newPlatform.setup.plugins @@ -137,7 +132,7 @@ export function shim( spaces: () => server.plugins.spaces, encryptedSavedObjects: newPlatform.start.plugins .encryptedSavedObjects as EncryptedSavedObjectsStartContract, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerStart(server)!, }; return { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index 87fa33a9cea58..394c13e1bd24f 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { schema } from '@kbn/config-schema'; import { AlertExecutorOptions } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; @@ -38,9 +38,7 @@ describe('Task Runner', () => { scheduledAt: new Date(), startedAt: new Date(), retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: { - startedAt: new Date(Date.now() - 5 * 60 * 1000), - }, + state: {}, taskType: 'alerting:test', params: { alertId: '1', @@ -110,7 +108,13 @@ describe('Task Runner', () => { test('successfully executes the task', async () => { const taskRunner = new TaskRunner( alertType, - mockedTaskInstance, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + previousStartedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }, + }, taskRunnerFactoryInitializerParams ); savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); @@ -141,6 +145,7 @@ describe('Task Runner', () => { } `); expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); + expect(call.previousStartedAt).toMatchInlineSnapshot(`1969-12-31T23:55:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); expect(call.name).toBe('alert-name'); expect(call.tags).toEqual(['alert-', '-tags']); @@ -261,7 +266,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:00:10.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); @@ -293,7 +297,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:00:10.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); @@ -400,7 +403,96 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:00:10.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + }); + + test('recovers gracefully when the Alert Task Runner throws an exception when fetching the encrypted attributes', async () => { + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:05:00.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); + }); + + test('recovers gracefully when the Alert Task Runner throws an exception when getting internal Services', async () => { + taskRunnerFactoryInitializerParams.getServices.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:05:00.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); + }); + + test('recovers gracefully when the Alert Task Runner throws an exception when fetching attributes', async () => { + savedObjectsClient.get.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:05:00.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, }, } `); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 42c332e82e034..0f643e3d3121c 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -8,16 +8,23 @@ import { pick, mapValues, omit } from 'lodash'; import { Logger } from '../../../../../../src/core/server'; import { SavedObject } from '../../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; -import { ConcreteTaskInstance } from '../../../task_manager'; +import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; import { AlertType, RawAlert, IntervalSchedule, Services, State, AlertInfoParams } from '../types'; -import { promiseResult, map } from '../lib/result_type'; +import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; type AlertInstances = Record; +const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; + +interface AlertTaskRunResult { + state: State; + runAt: Date; +} + export class TaskRunner { private context: TaskRunnerContext; private logger: Logger; @@ -152,7 +159,7 @@ export class TaskRunner { params, state: alertTypeState, startedAt: this.taskInstance.startedAt!, - previousStartedAt, + previousStartedAt: previousStartedAt && new Date(previousStartedAt), spaceId, namespace, name, @@ -190,7 +197,7 @@ export class TaskRunner { }; } - async validateAndRunAlert( + async validateAndExecuteAlert( services: Services, apiKey: string | null, attributes: RawAlert, @@ -217,11 +224,9 @@ export class TaskRunner { ); } - async run() { + async loadAlertAttributesAndRun(): Promise> { const { params: { alertId, spaceId }, - startedAt: previousStartedAt, - state: originalState, } = this.taskInstance; const apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId); @@ -233,11 +238,34 @@ export class TaskRunner { alertId ); + return { + state: await promiseResult( + this.validateAndExecuteAlert(services, apiKey, attributes, references) + ), + runAt: asOk( + getNextRunAt( + new Date(this.taskInstance.startedAt!), + // we do not currently have a good way of returning the type + // from SavedObjectsClient, and as we currenrtly require a schedule + // and we only support `interval`, we can cast this safely + attributes.schedule as IntervalSchedule + ) + ), + }; + } + + async run(): Promise { + const { + params: { alertId }, + startedAt: previousStartedAt, + state: originalState, + } = this.taskInstance; + + const { state, runAt } = await errorAsAlertTaskRunResult(this.loadAlertAttributesAndRun()); + return { state: map( - await promiseResult( - this.validateAndRunAlert(services, apiKey, attributes, references) - ), + state, (stateUpdates: State) => { return { ...stateUpdates, @@ -252,13 +280,32 @@ export class TaskRunner { }; } ), - runAt: getNextRunAt( - new Date(this.taskInstance.startedAt!), - // we do not currently have a good way of returning the type - // from SavedObjectsClient, and as we currenrtly require a schedule - // and we only support `interval`, we can cast this safely - attributes.schedule as IntervalSchedule + runAt: resolveErr(runAt, () => + getNextRunAt( + new Date(), + // if we fail at this point we wish to recover but don't have access to the Alert's + // attributes, so we'll use a default interval to prevent the underlying task from + // falling into a failed state + FALLBACK_RETRY_INTERVAL + ) ), }; } } + +/** + * If an error is thrown, wrap it in an AlertTaskRunResult + * so that we can treat each field independantly + */ +async function errorAsAlertTaskRunResult( + future: Promise> +): Promise> { + try { + return await future; + } catch (e) { + return { + state: asErr(e), + runAt: asErr(e), + }; + } +} diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 2ea1256352bec..543b9e7d32e12 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -5,7 +5,7 @@ */ import sinon from 'sinon'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; import { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts index 7186e1e729bda..7178fa4f01282 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Logger } from '../../../../../../src/core/server'; -import { RunContext } from '../../../task_manager'; +import { RunContext } from '../../../../../plugins/task_manager/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; import { diff --git a/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index e345ca3552e5a..8f87b3473b2e4 100644 --- a/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -4,6 +4,8 @@ exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; +exports[`Error DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`; exports[`Error ERROR_EXC_HANDLED 1`] = `undefined`; @@ -112,6 +114,8 @@ exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; +exports[`Span DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Span ERROR_CULPRIT 1`] = `undefined`; exports[`Span ERROR_EXC_HANDLED 1`] = `undefined`; @@ -220,6 +224,8 @@ exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; +exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Transaction ERROR_CULPRIT 1`] = `undefined`; exports[`Transaction ERROR_EXC_HANDLED 1`] = `undefined`; diff --git a/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts index 0d7ff3114e73f..ce2db4964a412 100644 --- a/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts @@ -14,6 +14,8 @@ export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; export const USER_AGENT_NAME = 'user_agent.name'; +export const DESTINATION_ADDRESS = 'destination.address'; + export const OBSERVER_VERSION_MAJOR = 'observer.version_major'; export const OBSERVER_LISTENING = 'observer.listening'; export const PROCESSOR_EVENT = 'processor.event'; diff --git a/x-pack/legacy/plugins/apm/common/service_map.ts b/x-pack/legacy/plugins/apm/common/service_map.ts new file mode 100644 index 0000000000000..fbaa489c45039 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/service_map.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ServiceConnectionNode { + 'service.name': string; + 'service.environment': string | null; + 'agent.name': string; +} +export interface ExternalConnectionNode { + 'destination.address': string; + 'span.type': string; + 'span.subtype': string; +} + +export type ConnectionNode = ServiceConnectionNode | ExternalConnectionNode; + +export interface Connection { + source: ConnectionNode; + destination: ConnectionNode; +} diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index cf2cbd2507215..0934cb0019f44 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -71,7 +71,8 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(false) + serviceMapEnabled: Joi.boolean().default(false), + serviceMapInitialTimeRange: Joi.number().default(60 * 1000 * 60) // last 1 hour }).default(); }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 238158c5bf224..d69fa5d895b9e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -26,7 +26,7 @@ interface CytoscapeProps { children?: ReactNode; elements: cytoscape.ElementDefinition[]; serviceName?: string; - style: CSSProperties; + style?: CSSProperties; } function useCytoscape(options: cytoscape.CytoscapeOptions) { @@ -69,18 +69,41 @@ export function Cytoscape({ // Set up cytoscape event handlers useEffect(() => { - if (cy) { - cy.on('data', event => { + const dataHandler: cytoscape.EventHandler = event => { + if (cy) { // Add the "primary" class to the node if its id matches the serviceName. if (cy.nodes().length > 0 && serviceName) { + cy.nodes().removeClass('primary'); cy.getElementById(serviceName).addClass('primary'); } if (event.cy.elements().length > 0) { cy.layout(cytoscapeOptions.layout as cytoscape.LayoutOptions).run(); } - }); + } + }; + const mouseoverHandler: cytoscape.EventHandler = event => { + event.target.addClass('hover'); + event.target.connectedEdges().addClass('nodeHover'); + }; + const mouseoutHandler: cytoscape.EventHandler = event => { + event.target.removeClass('hover'); + event.target.connectedEdges().removeClass('nodeHover'); + }; + + if (cy) { + cy.on('data', dataHandler); + cy.on('mouseover', 'edge, node', mouseoverHandler); + cy.on('mouseout', 'edge, node', mouseoutHandler); } + + return () => { + if (cy) { + cy.removeListener('data', undefined, dataHandler); + cy.removeListener('mouseover', 'edge, node', mouseoverHandler); + cy.removeListener('mouseout', 'edge, node', mouseoutHandler); + } + }; }, [cy, serviceName]); return ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx new file mode 100644 index 0000000000000..efafdbcecd41c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { EuiProgress, EuiText, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; + +const Container = styled.div` + position: relative; +`; + +const Overlay = styled.div` + position: absolute; + top: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: ${theme.gutterTypes.gutterMedium}; +`; + +const ProgressBarContainer = styled.div` + width: 50%; + max-width: 600px; +`; + +interface Props { + children: React.ReactNode; + isLoading: boolean; + percentageLoaded: number; +} + +export const LoadingOverlay = ({ + children, + isLoading, + percentageLoaded +}: Props) => ( + + {isLoading && ( + + + + + + + {i18n.translate('xpack.apm.loadingServiceMap', { + defaultMessage: + 'Loading service map... This might take a short while.' + })} + + + )} + {children} + +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx new file mode 100644 index 0000000000000..a8c45c83a382a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @elastic/eui/href-or-on-click */ + +import { EuiButton, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { MouseEvent } from 'react'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { getAPMHref } from '../../../shared/Links/apm/APMLink'; + +interface ButtonsProps { + focusedServiceName?: string; + onFocusClick?: (event: MouseEvent) => void; + selectedNodeServiceName: string; +} + +export function Buttons({ + focusedServiceName, + onFocusClick = () => {}, + selectedNodeServiceName +}: ButtonsProps) { + const currentSearch = useUrlParams().urlParams.kuery ?? ''; + const detailsUrl = getAPMHref( + `/services/${selectedNodeServiceName}/transactions`, + currentSearch + ); + const focusUrl = getAPMHref( + `/services/${selectedNodeServiceName}/service-map`, + currentSearch + ); + + const isAlreadyFocused = focusedServiceName === selectedNodeServiceName; + + return ( + <> + + + {i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', { + defaultMessage: 'Service Details' + })} + + + + + {i18n.translate('xpack.apm.serviceMap.focusMapButtonText', { + defaultMessage: 'Focus map' + })} + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx new file mode 100644 index 0000000000000..1c5443e404f9b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; + +const ItemRow = styled.div` + line-height: 2; +`; + +const ItemTitle = styled.dt` + color: ${lightTheme.textColors.subdued}; +`; + +const ItemDescription = styled.dd``; + +interface InfoProps { + type: string; + subtype?: string; +} + +export function Info({ type, subtype }: InfoProps) { + const listItems = [ + { + title: i18n.translate('xpack.apm.serviceMap.typePopoverMetric', { + defaultMessage: 'Type' + }), + description: type + }, + { + title: i18n.translate('xpack.apm.serviceMap.subtypePopoverMetric', { + defaultMessage: 'Subtype' + }), + description: subtype + } + ]; + + return ( + <> + {listItems.map( + ({ title, description }) => + description && ( + + {title} + {description} + + ) + )} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx new file mode 100644 index 0000000000000..8ce6d9d57c4ac --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFlexGroup, + EuiLoadingSpinner, + EuiFlexItem, + EuiBadge +} from '@elastic/eui'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import { isNumber } from 'lodash'; +import React from 'react'; +import styled from 'styled-components'; +import { ServiceNodeMetrics } from '../../../../../server/lib/service_map/get_service_map_service_node_info'; +import { + asDuration, + asPercent, + toMicroseconds, + tpmUnit +} from '../../../../utils/formatters'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { useFetcher } from '../../../../hooks/useFetcher'; + +function LoadingSpinner() { + return ( + + + + ); +} + +const ItemRow = styled('tr')` + line-height: 2; +`; + +const ItemTitle = styled('td')` + color: ${lightTheme.textColors.subdued}; + padding-right: 1rem; +`; + +const ItemDescription = styled('td')` + text-align: right; +`; + +const na = i18n.translate('xpack.apm.serviceMap.NotAvailableMetric', { + defaultMessage: 'N/A' +}); + +interface MetricListProps { + serviceName: string; +} + +export function ServiceMetricList({ serviceName }: MetricListProps) { + const { + urlParams: { start, end, environment } + } = useUrlParams(); + + const { data = {} as ServiceNodeMetrics, status } = useFetcher( + callApmApi => { + if (serviceName && start && end) { + return callApmApi({ + pathname: '/api/apm/service-map/service/{serviceName}', + params: { + path: { + serviceName + }, + query: { + start, + end, + environment + } + } + }); + } + }, + [serviceName, start, end, environment], + { + preservePreviousData: false + } + ); + + const { + avgTransactionDuration, + avgRequestsPerMinute, + avgErrorsPerMinute, + avgCpuUsage, + avgMemoryUsage, + numInstances + } = data; + const isLoading = status === 'loading'; + + const listItems = [ + { + title: i18n.translate( + 'xpack.apm.serviceMap.avgTransDurationPopoverMetric', + { + defaultMessage: 'Trans. duration (avg.)' + } + ), + description: isNumber(avgTransactionDuration) + ? asDuration(toMicroseconds(avgTransactionDuration, 'milliseconds')) + : na + }, + { + title: i18n.translate( + 'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric', + { + defaultMessage: 'Req. per minute (avg.)' + } + ), + description: isNumber(avgRequestsPerMinute) + ? `${avgRequestsPerMinute.toFixed(2)} ${tpmUnit('request')}` + : na + }, + { + title: i18n.translate( + 'xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric', + { + defaultMessage: 'Errors per minute (avg.)' + } + ), + description: avgErrorsPerMinute?.toFixed(2) ?? na + }, + { + title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverMetric', { + defaultMessage: 'CPU usage (avg.)' + }), + description: isNumber(avgCpuUsage) ? asPercent(avgCpuUsage, 1) : na + }, + { + title: i18n.translate( + 'xpack.apm.serviceMap.avgMemoryUsagePopoverMetric', + { + defaultMessage: 'Memory usage (avg.)' + } + ), + description: isNumber(avgMemoryUsage) ? asPercent(avgMemoryUsage, 1) : na + } + ]; + return isLoading ? ( + + ) : ( + <> + {numInstances && numInstances > 1 && ( + +
+ + {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { + values: { numInstances }, + defaultMessage: '{numInstances} instances' + })} + +
+
+ )} + + + + {listItems.map(({ title, description }) => ( + + {title} + {description} + + ))} + +
+ + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx new file mode 100644 index 0000000000000..dfb78aaa0214c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPopover, + EuiTitle +} from '@elastic/eui'; +import cytoscape from 'cytoscape'; +import React, { + CSSProperties, + useContext, + useEffect, + useState, + useCallback +} from 'react'; +import { CytoscapeContext } from '../Cytoscape'; +import { Buttons } from './Buttons'; +import { Info } from './Info'; +import { ServiceMetricList } from './ServiceMetricList'; + +const popoverMinWidth = 280; + +interface PopoverProps { + focusedServiceName?: string; +} + +export function Popover({ focusedServiceName }: PopoverProps) { + const cy = useContext(CytoscapeContext); + const [selectedNode, setSelectedNode] = useState< + cytoscape.NodeSingular | undefined + >(undefined); + const onFocusClick = useCallback(() => setSelectedNode(undefined), [ + setSelectedNode + ]); + + useEffect(() => { + const selectHandler: cytoscape.EventHandler = event => { + setSelectedNode(event.target); + }; + const unselectHandler: cytoscape.EventHandler = () => { + setSelectedNode(undefined); + }; + + if (cy) { + cy.on('select', 'node', selectHandler); + cy.on('unselect', 'node', unselectHandler); + cy.on('data viewport', unselectHandler); + } + + return () => { + if (cy) { + cy.removeListener('select', 'node', selectHandler); + cy.removeListener('unselect', 'node', unselectHandler); + cy.removeListener('data viewport', undefined, unselectHandler); + } + }; + }, [cy]); + + const renderedHeight = selectedNode?.renderedHeight() ?? 0; + const renderedWidth = selectedNode?.renderedWidth() ?? 0; + const { x, y } = selectedNode?.renderedPosition() ?? { x: 0, y: 0 }; + const isOpen = !!selectedNode; + const selectedNodeServiceName: string = selectedNode?.data('id'); + const isService = selectedNode?.data('type') === 'service'; + const triggerStyle: CSSProperties = { + background: 'transparent', + height: renderedHeight, + position: 'absolute', + width: renderedWidth + }; + const trigger =
; + + const zoom = cy?.zoom() ?? 1; + const height = selectedNode?.height() ?? 0; + const translateY = y - (zoom + 1) * (height / 2); + const popoverStyle: CSSProperties = { + position: 'absolute', + transform: `translate(${x}px, ${translateY}px)` + }; + const data = selectedNode?.data() ?? {}; + const label = data.label || selectedNodeServiceName; + + return ( + {}} + isOpen={isOpen} + style={popoverStyle} + > + + + +

{label}

+
+ +
+ + + {isService ? ( + + ) : ( + + )} + + {isService && ( + + )} +
+
+ ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 03ae9d0c287e5..1a6247388a655 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -3,22 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import cytoscape from 'cytoscape'; import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { icons, defaultIcon } from './icons'; +import cytoscape from 'cytoscape'; +import { defaultIcon, iconForNode } from './icons'; const layout = { - animate: true, - animationEasing: theme.euiAnimSlightBounce as cytoscape.Css.TransitionTimingFunction, - animationDuration: parseInt(theme.euiAnimSpeedFast, 10), name: 'dagre', nodeDimensionsIncludeLabels: true, - rankDir: 'LR', - spacingFactor: 2 + rankDir: 'LR' }; -function isDatabaseOrExternal(agentName: string) { - return agentName === 'database' || agentName === 'external'; +function isService(el: cytoscape.NodeSingular) { + return el.data('type') === 'service'; } const style: cytoscape.Stylesheet[] = [ @@ -31,11 +27,11 @@ const style: cytoscape.Stylesheet[] = [ // // @ts-ignore 'background-image': (el: cytoscape.NodeSingular) => - icons[el.data('agentName')] || defaultIcon, + iconForNode(el) ?? defaultIcon, 'background-height': (el: cytoscape.NodeSingular) => - isDatabaseOrExternal(el.data('agentName')) ? '40%' : '80%', + isService(el) ? '80%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => - isDatabaseOrExternal(el.data('agentName')) ? '40%' : '80%', + isService(el) ? '80%' : '40%', 'border-color': (el: cytoscape.NodeSingular) => el.hasClass('primary') ? theme.euiColorSecondary @@ -47,11 +43,11 @@ const style: cytoscape.Stylesheet[] = [ 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', 'font-size': theme.euiFontSizeXS, height: theme.avatarSizing.l.size, - label: 'data(id)', + label: 'data(label)', 'min-zoomed-font-size': theme.euiSizeL, 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => - isDatabaseOrExternal(el.data('agentName')) ? 'diamond' : 'ellipse', + isService(el) ? 'ellipse' : 'diamond', 'text-background-color': theme.euiColorLightestShade, 'text-background-opacity': 0, 'text-background-padding': theme.paddingSizes.xs, @@ -76,14 +72,24 @@ const style: cytoscape.Stylesheet[] = [ // // @ts-ignore 'target-distance-from-node': theme.paddingSizes.xs, - width: 2 + width: 1, + 'source-arrow-shape': 'none' + } + }, + { + selector: 'edge[bidirectional]', + style: { + 'source-arrow-shape': 'triangle', + 'target-arrow-shape': 'triangle', + // @ts-ignore + 'source-distance-from-node': theme.paddingSizes.xs, + 'target-distance-from-node': theme.paddingSizes.xs } } ]; export const cytoscapeOptions: cytoscape.CytoscapeOptions = { autoungrabify: true, - autounselectify: true, boxSelectionEnabled: false, layout, maxZoom: 3, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts new file mode 100644 index 0000000000000..106e9a1d82f29 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ValuesType } from 'utility-types'; +import { sortBy, isEqual } from 'lodash'; +import { Connection, ConnectionNode } from '../../../../common/service_map'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; +import { getAPMHref } from '../../shared/Links/apm/APMLink'; + +function getConnectionNodeId(node: ConnectionNode): string { + if ('destination.address' in node) { + // use a prefix to distinguish exernal destination ids from services + return `>${node['destination.address']}`; + } + return node['service.name']; +} + +function getConnectionId(connection: Connection) { + return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId( + connection.destination + )}`; +} +export function getCytoscapeElements( + responses: ServiceMapAPIResponse[], + search: string +) { + const discoveredServices = responses.flatMap( + response => response.discoveredServices + ); + + const serviceNodes = responses + .flatMap(response => response.services) + .map(service => ({ + ...service, + id: service['service.name'] + })); + + // maps destination.address to service.name if possible + function getConnectionNode(node: ConnectionNode) { + let mappedNode: ConnectionNode | undefined; + + if ('destination.address' in node) { + mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; + } + + if (!mappedNode) { + mappedNode = node; + } + + return { + ...mappedNode, + id: getConnectionNodeId(mappedNode) + }; + } + + // build connections with mapped nodes + const connections = responses + .flatMap(response => response.connections) + .map(connection => { + const source = getConnectionNode(connection.source); + const destination = getConnectionNode(connection.destination); + + return { + source, + destination, + id: getConnectionId({ source, destination }) + }; + }) + .filter(connection => connection.source.id !== connection.destination.id); + + const nodes = connections + .flatMap(connection => [connection.source, connection.destination]) + .concat(serviceNodes); + + type ConnectionWithId = ValuesType; + type ConnectionNodeWithId = ValuesType; + + const connectionsById = connections.reduce((connectionMap, connection) => { + return { + ...connectionMap, + [connection.id]: connection + }; + }, {} as Record); + + const nodesById = nodes.reduce((nodeMap, node) => { + return { + ...nodeMap, + [node.id]: node + }; + }, {} as Record); + + const cyNodes = (Object.values(nodesById) as ConnectionNodeWithId[]).map( + node => { + let data = {}; + + if ('service.name' in node) { + data = { + href: getAPMHref( + `/services/${node['service.name']}/service-map`, + search + ), + agentName: node['agent.name'] || node['agent.name'], + type: 'service' + }; + } + + if ('span.type' in node) { + data = { + // For nodes with span.type "db", convert it to "database". Otherwise leave it as-is. + type: node['span.type'] === 'db' ? 'database' : node['span.type'], + // Externals should not have a subtype so make it undefined if the type is external. + subtype: node['span.type'] !== 'external' && node['span.subtype'] + }; + } + + return { + group: 'nodes' as const, + data: { + id: node.id, + label: + 'service.name' in node + ? node['service.name'] + : node['destination.address'], + ...data + } + }; + } + ); + + // instead of adding connections in two directions, + // we add a `bidirectional` flag to use in styling + const dedupedConnections = (sortBy( + Object.values(connectionsById), + // make sure that order is stable + 'id' + ) as ConnectionWithId[]).reduce< + Array + >((prev, connection) => { + const reversedConnection = prev.find( + c => + c.destination.id === connection.source.id && + c.source.id === connection.destination.id + ); + + if (reversedConnection) { + reversedConnection.bidirectional = true; + return prev; + } + + return prev.concat(connection); + }, []); + + const cyEdges = dedupedConnections.map(connection => { + return { + group: 'edges' as const, + data: { + id: connection.id, + source: connection.source.id, + target: connection.destination.id, + bidirectional: connection.bidirectional ? true : undefined + } + }; + }, []); + + return [...cyNodes, ...cyEdges]; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index d5cfb49e458c6..722f64c6a7e58 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -5,7 +5,9 @@ */ import theme from '@elastic/eui/dist/eui_theme_light.json'; +import cytoscape from 'cytoscape'; import databaseIcon from './icons/database.svg'; +import documentsIcon from './icons/documents.svg'; import globeIcon from './icons/globe.svg'; function getAvatarIcon( @@ -24,10 +26,16 @@ function getAvatarIcon( } // The colors here are taken from the logos of the corresponding technologies -export const icons: { [key: string]: string } = { +const icons: { [key: string]: string } = { + cache: databaseIcon, database: databaseIcon, - dotnet: getAvatarIcon('.N', '#8562AD'), external: globeIcon, + messaging: documentsIcon, + resource: globeIcon +}; + +const serviceIcons: { [key: string]: string } = { + dotnet: getAvatarIcon('.N', '#8562AD'), go: getAvatarIcon('Go', '#00A9D6'), java: getAvatarIcon('Jv', '#41717E'), 'js-base': getAvatarIcon('JS', '#F0DB4E', theme.euiTextColor), @@ -37,3 +45,12 @@ export const icons: { [key: string]: string } = { }; export const defaultIcon = getAvatarIcon(); + +export function iconForNode(node: cytoscape.NodeSingular) { + const type = node.data('type'); + if (type === 'service') { + return serviceIcons[node.data('agentName') as string]; + } else { + return icons[type]; + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg new file mode 100644 index 0000000000000..b0648d14f20ba --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg @@ -0,0 +1 @@ + diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index cc09975a344b5..a8e6f964f4d0c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,14 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiButton } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; -import React from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; +import { i18n } from '@kbn/i18n'; +import { ElementDefinition } from 'cytoscape'; +import { find, isEqual } from 'lodash'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { useCallApmApi } from '../../../hooks/useCallApmApi'; +import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; import { useLicense } from '../../../hooks/useLicense'; +import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; +import { getCytoscapeElements } from './get_cytoscape_elements'; +import { LoadingOverlay } from './LoadingOverlay'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; +import { Popover } from './Popover'; interface ServiceMapProps { serviceName?: string; @@ -37,37 +55,160 @@ ${theme.euiColorLightShade}`, margin: `-${theme.gutterTypes.gutterLarge}` }; +const MAX_REQUESTS = 5; + export function ServiceMap({ serviceName }: ServiceMapProps) { - const { - urlParams: { start, end } - } = useUrlParams(); + const callApmApi = useCallApmApi(); + const license = useLicense(); + const { search } = useLocation(); + const { urlParams, uiFilters } = useUrlParams(); + const { notifications } = useApmPluginContext().core; + const params = useDeepObjectIdentity({ + start: urlParams.start, + end: urlParams.end, + environment: urlParams.environment, + serviceName, + uiFilters: { + ...uiFilters, + environment: undefined + } + }); + + const renderedElements = useRef([]); + const openToast = useRef(null); + + const [responses, setResponses] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [percentageLoaded, setPercentageLoaded] = useState(0); + const [, _setUnusedState] = useState(false); + + const elements = useMemo(() => getCytoscapeElements(responses, search), [ + responses, + search + ]); + + const forceUpdate = useCallback(() => _setUnusedState(value => !value), []); + + const getNext = useCallback( + async (input: { reset?: boolean; after?: string | undefined }) => { + const { start, end, uiFilters: strippedUiFilters, ...query } = params; + + if (input.reset) { + renderedElements.current = []; + setResponses([]); + } - const { data } = useFetcher( - callApmApi => { if (start && end) { - return callApmApi({ - pathname: '/api/apm/service-map', - params: { query: { start, end } } - }); + setIsLoading(true); + try { + const data = await callApmApi({ + pathname: '/api/apm/service-map', + params: { + query: { + ...query, + start, + end, + uiFilters: JSON.stringify(strippedUiFilters), + after: input.after + } + } + }); + setResponses(resp => resp.concat(data)); + setIsLoading(false); + + const shouldGetNext = + responses.length + 1 < MAX_REQUESTS && data.after; + + if (shouldGetNext) { + setPercentageLoaded(value => value + 30); // increase loading bar 30% + await getNext({ after: data.after }); + } + } catch (error) { + setIsLoading(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.apm.errorServiceMapData', { + defaultMessage: `Error loading service connections` + }) + }); + } } }, - [start, end] + [callApmApi, params, responses.length, notifications.toasts] ); - const elements = Array.isArray(data) ? data : []; - const license = useLicense(); + useEffect(() => { + const loadServiceMaps = async () => { + setPercentageLoaded(5); + await getNext({ reset: true }); + setPercentageLoaded(100); + }; + + loadServiceMaps(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); + + useEffect(() => { + if (renderedElements.current.length === 0) { + renderedElements.current = elements; + return; + } + + const newElements = elements.filter(element => { + return !find(renderedElements.current, el => isEqual(el, element)); + }); + + const updateMap = () => { + renderedElements.current = elements; + if (openToast.current) { + notifications.toasts.remove(openToast.current); + } + forceUpdate(); + }; + + if (newElements.length > 0 && percentageLoaded === 100) { + openToast.current = notifications.toasts.add({ + title: i18n.translate('xpack.apm.newServiceMapData', { + defaultMessage: `Newly discovered connections are available.` + }), + onClose: () => { + openToast.current = null; + }, + toastLifeTimeMs: 24 * 60 * 60 * 1000, + text: toMountPoint( + + {i18n.translate('xpack.apm.updateServiceMap', { + defaultMessage: 'Update map' + })} + + ) + }).id; + } + + return () => { + if (openToast.current) { + notifications.toasts.remove(openToast.current); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elements, percentageLoaded]); + const isValidPlatinumLicense = license?.isActive && (license?.type === 'platinum' || license?.type === 'trial'); return isValidPlatinumLicense ? ( - - - + + + + + + ) : ( ); diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap index ece396bc4cfc4..c95855c117047 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap @@ -808,8 +808,8 @@ Object { }, }, "serviceColors": Object { - "opbeans-node": "#3185fc", - "opbeans-ruby": "#00b3a4", + "opbeans-node": "#6092c0", + "opbeans-ruby": "#5bbaa0", }, } `; @@ -1212,7 +1212,7 @@ Object { }, }, "serviceColors": Object { - "opbeans-ruby": "#3185fc", + "opbeans-ruby": "#6092c0", }, } `; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap b/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap index 59679bfe11641..655fc5a25b9ef 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap +++ b/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap @@ -52,6 +52,7 @@ exports[`ManagedTable component should render a page-full of items, with default }, } } + tableLayout="fixed" /> `; @@ -99,5 +100,6 @@ exports[`ManagedTable component should render when specifying initial values 1`] }, } } + tableLayout="fixed" /> `; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx index 25f8128b27211..a8e6bc0a648af 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx @@ -20,7 +20,7 @@ export const SelectWithPlaceholder: typeof EuiSelect = props => ( {...props} options={[ { text: props.placeholder, value: NO_SELECTION }, - ...props.options + ...(props.options || []) ]} value={isEmpty(props.value) ? NO_SELECTION : props.value} onChange={e => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx index 84c2801a45049..51056fae50360 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx @@ -35,9 +35,13 @@ const FrameHeading: React.FC = ({ stackframe, isLibraryFrame }) => { ? LibraryFrameFileDetail : AppFrameFileDetail; const lineNumber = stackframe.line.number; + + const name = + 'filename' in stackframe ? stackframe.filename : stackframe.classname; + return ( - {stackframe.filename} in{' '} + {name} in{' '} {stackframe.function} {lineNumber > 0 && ( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap index 557751a0f0226..1bf125c301644 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap @@ -3,7 +3,7 @@ exports[`when response has data Initially should have 3 legends 1`] = ` Array [ Object { - "color": "#3185fc", + "color": "#6092c0", "disabled": undefined, "onClick": [Function], "text": @@ -14,7 +14,7 @@ Array [ , }, Object { - "color": "#e6c220", + "color": "#fae181", "disabled": undefined, "onClick": [Function], "text": @@ -22,7 +22,7 @@ Array [ , }, Object { - "color": "#f98510", + "color": "#f19f58", "disabled": undefined, "onClick": [Function], "text": @@ -442,7 +442,7 @@ Array [ style={ Object { "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeDasharray": undefined, "strokeWidth": undefined, } @@ -463,9 +463,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -480,9 +480,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -497,9 +497,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -514,9 +514,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -531,9 +531,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -548,9 +548,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -565,9 +565,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -582,9 +582,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -599,9 +599,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -616,9 +616,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -633,9 +633,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -650,9 +650,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -667,9 +667,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -684,9 +684,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -701,9 +701,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -718,9 +718,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -735,9 +735,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -752,9 +752,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -769,9 +769,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -786,9 +786,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -803,9 +803,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -820,9 +820,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -837,9 +837,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -854,9 +854,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -871,9 +871,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -888,9 +888,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -905,9 +905,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -922,9 +922,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -939,9 +939,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -956,9 +956,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -973,9 +973,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -995,7 +995,7 @@ Array [ style={ Object { "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeDasharray": undefined, "strokeWidth": undefined, } @@ -1016,9 +1016,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1033,9 +1033,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1050,9 +1050,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1067,9 +1067,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1084,9 +1084,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1101,9 +1101,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1118,9 +1118,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1135,9 +1135,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1152,9 +1152,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1169,9 +1169,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1186,9 +1186,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1203,9 +1203,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1220,9 +1220,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1237,9 +1237,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1254,9 +1254,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1271,9 +1271,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1288,9 +1288,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1305,9 +1305,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1322,9 +1322,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1339,9 +1339,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1356,9 +1356,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1373,9 +1373,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1390,9 +1390,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1407,9 +1407,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1424,9 +1424,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1441,9 +1441,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1458,9 +1458,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1475,9 +1475,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1492,9 +1492,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1509,9 +1509,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1526,9 +1526,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -1548,7 +1548,7 @@ Array [ style={ Object { "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeDasharray": undefined, "strokeWidth": undefined, } @@ -1569,9 +1569,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1586,9 +1586,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1603,9 +1603,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1620,9 +1620,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1637,9 +1637,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1654,9 +1654,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1671,9 +1671,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1688,9 +1688,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1705,9 +1705,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1722,9 +1722,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1739,9 +1739,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1756,9 +1756,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1773,9 +1773,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1790,9 +1790,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1807,9 +1807,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1824,9 +1824,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1841,9 +1841,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1858,9 +1858,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1875,9 +1875,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1892,9 +1892,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1909,9 +1909,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1926,9 +1926,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1943,9 +1943,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1960,9 +1960,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1977,9 +1977,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -1994,9 +1994,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -2011,9 +2011,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -2028,9 +2028,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -2045,9 +2045,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -2062,9 +2062,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -2079,9 +2079,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -2651,7 +2651,7 @@ Array [ width: 11px; height: 11px; margin-right: 5.5px; - background: #3185fc; + background: #6092c0; border-radius: 100%; } @@ -2659,7 +2659,7 @@ Array [ width: 11px; height: 11px; margin-right: 5.5px; - background: #e6c220; + background: #fae181; border-radius: 100%; } @@ -2667,7 +2667,7 @@ Array [ width: 11px; height: 11px; margin-right: 5.5px; - background: #f98510; + background: #f19f58; border-radius: 100%; } @@ -2723,14 +2723,14 @@ Array [ onClick={[Function]} > @@ -2764,14 +2764,14 @@ Array [ onClick={[Function]} > @@ -2798,14 +2798,14 @@ Array [ onClick={[Function]} > @@ -2849,17 +2849,17 @@ exports[`when response has data when dragging without releasing should display S exports[`when response has data when setting hoverX should display tooltip 1`] = ` Array [ Object { - "color": "#3185fc", + "color": "#6092c0", "text": "Avg.", "value": 438704.4, }, Object { - "color": "#e6c220", + "color": "#fae181", "text": "95th", "value": 1557383.999999999, }, Object { - "color": "#f98510", + "color": "#f19f58", "text": "99th", "value": 1820377.1200000006, }, @@ -2891,7 +2891,7 @@ Array [ width: 8px; height: 8px; margin-right: 4px; - background: #3185fc; + background: #6092c0; border-radius: 100%; } @@ -2899,7 +2899,7 @@ Array [ width: 8px; height: 8px; margin-right: 4px; - background: #e6c220; + background: #fae181; border-radius: 100%; } @@ -2907,7 +2907,7 @@ Array [ width: 8px; height: 8px; margin-right: 4px; - background: #f98510; + background: #f19f58; border-radius: 100%; } @@ -3378,7 +3378,7 @@ Array [ style={ Object { "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeDasharray": undefined, "strokeWidth": undefined, } @@ -3399,9 +3399,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3416,9 +3416,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3433,9 +3433,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3450,9 +3450,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3467,9 +3467,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3484,9 +3484,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3501,9 +3501,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3518,9 +3518,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3535,9 +3535,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3552,9 +3552,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3569,9 +3569,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3586,9 +3586,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3603,9 +3603,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3620,9 +3620,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3637,9 +3637,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3654,9 +3654,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3671,9 +3671,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3688,9 +3688,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3705,9 +3705,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3722,9 +3722,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3739,9 +3739,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3756,9 +3756,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3773,9 +3773,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3790,9 +3790,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3807,9 +3807,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3824,9 +3824,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3841,9 +3841,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3858,9 +3858,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3875,9 +3875,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3892,9 +3892,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3909,9 +3909,9 @@ Array [ r={0.5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -3931,7 +3931,7 @@ Array [ style={ Object { "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeDasharray": undefined, "strokeWidth": undefined, } @@ -3952,9 +3952,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -3969,9 +3969,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -3986,9 +3986,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4003,9 +4003,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4020,9 +4020,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4037,9 +4037,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4054,9 +4054,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4071,9 +4071,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4088,9 +4088,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4105,9 +4105,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4122,9 +4122,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4139,9 +4139,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4156,9 +4156,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4173,9 +4173,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4190,9 +4190,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4207,9 +4207,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4224,9 +4224,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4241,9 +4241,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4258,9 +4258,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4275,9 +4275,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4292,9 +4292,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4309,9 +4309,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4326,9 +4326,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4343,9 +4343,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4360,9 +4360,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4377,9 +4377,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4394,9 +4394,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4411,9 +4411,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4428,9 +4428,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4445,9 +4445,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4462,9 +4462,9 @@ Array [ r={0.5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -4484,7 +4484,7 @@ Array [ style={ Object { "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeDasharray": undefined, "strokeWidth": undefined, } @@ -4505,9 +4505,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4522,9 +4522,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4539,9 +4539,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4556,9 +4556,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4573,9 +4573,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4590,9 +4590,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4607,9 +4607,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4624,9 +4624,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4641,9 +4641,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4658,9 +4658,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4675,9 +4675,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4692,9 +4692,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4709,9 +4709,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4726,9 +4726,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4743,9 +4743,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4760,9 +4760,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4777,9 +4777,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4794,9 +4794,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4811,9 +4811,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4828,9 +4828,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4845,9 +4845,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4862,9 +4862,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4879,9 +4879,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4896,9 +4896,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4913,9 +4913,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4930,9 +4930,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4947,9 +4947,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4964,9 +4964,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4981,9 +4981,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -4998,9 +4998,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -5015,9 +5015,9 @@ Array [ r={0.5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -5072,9 +5072,9 @@ Array [ r={5} style={ Object { - "fill": "#f98510", + "fill": "#f19f58", "opacity": 1, - "stroke": "#f98510", + "stroke": "#f19f58", "strokeWidth": 1, } } @@ -5089,9 +5089,9 @@ Array [ r={5} style={ Object { - "fill": "#e6c220", + "fill": "#fae181", "opacity": 1, - "stroke": "#e6c220", + "stroke": "#fae181", "strokeWidth": 1, } } @@ -5106,9 +5106,9 @@ Array [ r={5} style={ Object { - "fill": "#3185fc", + "fill": "#6092c0", "opacity": 1, - "stroke": "#3185fc", + "stroke": "#6092c0", "strokeWidth": 1, } } @@ -5158,7 +5158,7 @@ Array [ className="c3" > @@ -5174,14 +5174,14 @@ Array [ fontSize="12px" > @@ -5204,7 +5204,7 @@ Array [ className="c3" > @@ -5220,14 +5220,14 @@ Array [ fontSize="12px" > @@ -5250,7 +5250,7 @@ Array [ className="c3" > @@ -5266,14 +5266,14 @@ Array [ fontSize="12px" > @@ -5830,7 +5830,7 @@ Array [ width: 11px; height: 11px; margin-right: 5.5px; - background: #3185fc; + background: #6092c0; border-radius: 100%; } @@ -5838,7 +5838,7 @@ Array [ width: 11px; height: 11px; margin-right: 5.5px; - background: #e6c220; + background: #fae181; border-radius: 100%; } @@ -5846,7 +5846,7 @@ Array [ width: 11px; height: 11px; margin-right: 5.5px; - background: #f98510; + background: #f19f58; border-radius: 100%; } @@ -5902,14 +5902,14 @@ Array [ onClick={[Function]} > @@ -5943,14 +5943,14 @@ Array [ onClick={[Function]} > @@ -5977,14 +5977,14 @@ Array [ onClick={[Function]} > diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap index da71e264ac099..f1c7d4826fe0c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap @@ -434,11 +434,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -453,11 +453,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -472,11 +472,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857142} @@ -491,11 +491,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -510,11 +510,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571405} @@ -529,11 +529,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857142} @@ -548,11 +548,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571405} @@ -567,11 +567,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571405} @@ -586,11 +586,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571377} @@ -605,11 +605,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571462} @@ -624,11 +624,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -643,11 +643,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -662,11 +662,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -681,11 +681,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -700,11 +700,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -719,11 +719,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -738,11 +738,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571547} @@ -757,11 +757,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -776,11 +776,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571547} @@ -795,11 +795,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -814,11 +814,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571433} @@ -833,11 +833,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571377} @@ -852,11 +852,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -871,11 +871,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -890,11 +890,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571604} @@ -909,11 +909,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -928,11 +928,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.62857142857149} @@ -947,11 +947,11 @@ exports[`Histogram Initially should have default markup 1`] = ` onMouseOver={[Function]} style={ Object { - "fill": "#98c2fd", + "fill": "#afc8df", "opacity": 1, "rx": "0px", "ry": "0px", - "stroke": "#98c2fd", + "stroke": "#afc8df", } } width={22.628571428571377} diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx index 0bd3896782603..62cdbd3bbc995 100644 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx @@ -16,8 +16,9 @@ export const LicenseContext = React.createContext( export function LicenseProvider({ children }: { children: React.ReactChild }) { const { license$ } = useApmPluginContext().plugins.licensing; - const license = useObservable(license$, { isActive: true } as ILicense); - const hasInvalidLicense = !license.isActive; + const license = useObservable(license$); + // if license is not loaded yet, consider it valid + const hasInvalidLicense = license?.isActive === false; // if license is invalid show an error message if (hasInvalidLicense) { diff --git a/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts b/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts index 2b0263f69db8f..1218bc726c3b7 100644 --- a/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts +++ b/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts @@ -21,7 +21,7 @@ describe('chartSelectors', () => { it('should return anomalyScoreSeries', () => { const data = [{ x0: 0, x: 10 }]; expect(getAnomalyScoreSeries(data)).toEqual({ - areaColor: 'rgba(146,0,0,0.1)', + areaColor: 'rgba(231,102,76,0.1)', color: 'none', data: [{ x0: 0, x: 10 }], hideLegend: true, @@ -57,7 +57,7 @@ describe('chartSelectors', () => { getResponseTimeSeries({ apmTimeseries, anomalyTimeseries: undefined }) ).toEqual([ { - color: '#3185fc', + color: '#6092c0', data: [ { x: 0, y: 100 }, { x: 1000, y: 200 } @@ -67,7 +67,7 @@ describe('chartSelectors', () => { type: 'linemark' }, { - color: '#e6c220', + color: '#fae181', data: [ { x: 0, y: 200 }, { x: 1000, y: 300 } @@ -77,7 +77,7 @@ describe('chartSelectors', () => { type: 'linemark' }, { - color: '#f98510', + color: '#f19f58', data: [ { x: 0, y: 300 }, { x: 1000, y: 400 } diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts index aeeb39733b5db..737eeac95516e 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts @@ -11,7 +11,7 @@ import { IndicesDeleteParams, IndicesCreateParams } from 'elasticsearch'; -import { merge } from 'lodash'; +import { merge, uniqueId } from 'lodash'; import { cloneDeep, isString } from 'lodash'; import { KibanaRequest } from 'src/core/server'; import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames'; @@ -127,6 +127,23 @@ export function getESClient( ? callAsInternalUser : callAsCurrentUser; + const debug = context.params.query._debug; + + function withTime( + fn: (log: typeof console.log) => Promise + ): Promise { + const log = console.log.bind(console, uniqueId()); + if (!debug) { + return fn(log); + } + const time = process.hrtime(); + return fn(log).then(data => { + const now = process.hrtime(time); + log(`took: ${Math.round(now[0] * 1000 + now[1] / 1e6)}ms`); + return data; + }); + } + return { search: async < TDocument = unknown, @@ -141,27 +158,29 @@ export function getESClient( apmOptions ); - if (context.params.query._debug) { - console.log(`--DEBUG ES QUERY--`); - console.log( - `${request.url.pathname} ${JSON.stringify(context.params.query)}` - ); - console.log(`GET ${nextParams.index}/_search`); - console.log(JSON.stringify(nextParams.body, null, 2)); - } + return withTime(log => { + if (context.params.query._debug) { + log(`--DEBUG ES QUERY--`); + log( + `${request.url.pathname} ${JSON.stringify(context.params.query)}` + ); + log(`GET ${nextParams.index}/_search`); + log(JSON.stringify(nextParams.body, null, 2)); + } - return (callMethod('search', nextParams) as unknown) as Promise< - ESSearchResponse - >; + return (callMethod('search', nextParams) as unknown) as Promise< + ESSearchResponse + >; + }); }, index: (params: APMIndexDocumentParams) => { - return callMethod('index', params); + return withTime(() => callMethod('index', params)); }, delete: (params: IndicesDeleteParams) => { - return callMethod('delete', params); + return withTime(() => callMethod('delete', params)); }, indicesCreate: (params: IndicesCreateParams) => { - return callMethod('indices.create', params); + return withTime(() => callMethod('indices.create', params)); } }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts index 8c6ed2ebcec75..870660c429ca3 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -43,7 +43,7 @@ const chartBase: ChartBase = { series }; -const percentUsedScript = { +export const percentMemoryUsedScript = { lang: 'expression', source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']` }; @@ -59,8 +59,8 @@ export async function getMemoryChartData( serviceNodeName, chartBase, aggs: { - memoryUsedAvg: { avg: { script: percentUsedScript } }, - memoryUsedMax: { max: { script: percentUsedScript } } + memoryUsedAvg: { avg: { script: percentMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentMemoryUsedScript } } }, additionalFilters: [ { diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts new file mode 100644 index 0000000000000..04e2a43a4b8f1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PromiseReturnType } from '../../../typings/common'; +import { + Setup, + SetupTimeRange, + SetupUIFilters +} from '../helpers/setup_request'; +import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; +import { getTraceSampleIds } from './get_trace_sample_ids'; +import { getServicesProjection } from '../../../common/projections/services'; +import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { + SERVICE_AGENT_NAME, + SERVICE_NAME +} from '../../../common/elasticsearch_fieldnames'; + +export interface IEnvOptions { + setup: Setup & SetupTimeRange & SetupUIFilters; + serviceName?: string; + environment?: string; + after?: string; +} + +async function getConnectionData({ + setup, + serviceName, + environment, + after +}: IEnvOptions) { + const { traceIds, after: nextAfter } = await getTraceSampleIds({ + setup, + serviceName, + environment, + after + }); + + const serviceMapData = traceIds.length + ? await getServiceMapFromTraceIds({ + setup, + serviceName, + environment, + traceIds + }) + : { connections: [], discoveredServices: [] }; + + return { + after: nextAfter, + ...serviceMapData + }; +} + +async function getServicesData(options: IEnvOptions) { + // only return services on the first request for the global service map + if (options.after) { + return []; + } + + const { setup } = options; + + const projection = getServicesProjection({ setup }); + + const { filter } = projection.body.query.bool; + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: { + ...projection.body.query.bool, + filter: options.serviceName + ? filter.concat({ + term: { + [SERVICE_NAME]: options.serviceName + } + }) + : filter + } + }, + aggs: { + services: { + terms: { + field: projection.body.aggs.services.terms.field, + size: 500 + }, + aggs: { + agent_name: { + terms: { + field: SERVICE_AGENT_NAME + } + } + } + } + } + } + }); + + const { client } = setup; + + const response = await client.search(params); + + return ( + response.aggregations?.services.buckets.map(bucket => { + return { + 'service.name': bucket.key as string, + 'agent.name': + (bucket.agent_name.buckets[0]?.key as string | undefined) || '', + 'service.environment': options.environment || null + }; + }) || [] + ); +} + +export type ServiceMapAPIResponse = PromiseReturnType; +export async function getServiceMap(options: IEnvOptions) { + const [connectionData, servicesData] = await Promise.all([ + getConnectionData(options), + getServicesData(options) + ]); + + return { + ...connectionData, + services: servicesData + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts new file mode 100644 index 0000000000000..d3711e9582d15 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -0,0 +1,281 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { uniq, find } from 'lodash'; +import { Setup } from '../helpers/setup_request'; +import { + TRACE_ID, + PROCESSOR_EVENT +} from '../../../common/elasticsearch_fieldnames'; +import { + Connection, + ServiceConnectionNode, + ConnectionNode, + ExternalConnectionNode +} from '../../../common/service_map'; + +export async function getServiceMapFromTraceIds({ + setup, + traceIds, + serviceName, + environment +}: { + setup: Setup; + traceIds: string[]; + serviceName?: string; + environment?: string; +}) { + const { indices, client } = setup; + + const serviceMapParams = { + index: [ + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + [PROCESSOR_EVENT]: ['span', 'transaction'] + } + }, + { + terms: { + [TRACE_ID]: traceIds + } + } + ] + } + }, + aggs: { + service_map: { + scripted_metric: { + init_script: { + lang: 'painless', + source: `state.eventsById = new HashMap(); + + String[] fieldsToCopy = new String[] { + 'parent.id', + 'service.name', + 'service.environment', + 'destination.address', + 'trace.id', + 'processor.event', + 'span.type', + 'span.subtype', + 'agent.name' + }; + state.fieldsToCopy = fieldsToCopy;` + }, + map_script: { + lang: 'painless', + source: `def id; + if (!doc['span.id'].empty) { + id = doc['span.id'].value; + } else { + id = doc['transaction.id'].value; + } + + def copy = new HashMap(); + copy.id = id; + + for(key in state.fieldsToCopy) { + if (!doc[key].empty) { + copy[key] = doc[key].value; + } + } + + state.eventsById[id] = copy` + }, + combine_script: { + lang: 'painless', + source: `return state.eventsById;` + }, + reduce_script: { + lang: 'painless', + source: ` + def getDestination ( def event ) { + def destination = new HashMap(); + destination['destination.address'] = event['destination.address']; + destination['span.type'] = event['span.type']; + destination['span.subtype'] = event['span.subtype']; + return destination; + } + + def processAndReturnEvent(def context, def eventId) { + if (context.processedEvents[eventId] != null) { + return context.processedEvents[eventId]; + } + + def event = context.eventsById[eventId]; + + if (event == null) { + return null; + } + + def service = new HashMap(); + service['service.name'] = event['service.name']; + service['service.environment'] = event['service.environment']; + service['agent.name'] = event['agent.name']; + + def basePath = new ArrayList(); + + def parentId = event['parent.id']; + def parent; + + if (parentId != null && parentId != event['id']) { + parent = processAndReturnEvent(context, parentId); + if (parent != null) { + /* copy the path from the parent */ + basePath.addAll(parent.path); + /* flag parent path for removal, as it has children */ + context.locationsToRemove.add(parent.path); + + /* if the parent has 'destination.address' set, and the service is different, + we've discovered a service */ + + if (parent['destination.address'] != null + && parent['destination.address'] != "" + && (parent['span.type'] == 'external' + || parent['span.type'] == 'messaging') + && (parent['service.name'] != event['service.name'] + || parent['service.environment'] != event['service.environment'] + ) + ) { + def parentDestination = getDestination(parent); + context.externalToServiceMap.put(parentDestination, service); + } + } + } + + def lastLocation = basePath.size() > 0 ? basePath[basePath.size() - 1] : null; + + def currentLocation = service; + + /* only add the current location to the path if it's different from the last one*/ + if (lastLocation == null || !lastLocation.equals(currentLocation)) { + basePath.add(currentLocation); + } + + /* if there is an outgoing span, create a new path */ + if (event['destination.address'] != null + && event['destination.address'] != '') { + def outgoingLocation = getDestination(event); + def outgoingPath = new ArrayList(basePath); + outgoingPath.add(outgoingLocation); + context.paths.add(outgoingPath); + } + + event.path = basePath; + + context.processedEvents[eventId] = event; + return event; + } + + def context = new HashMap(); + + context.processedEvents = new HashMap(); + context.eventsById = new HashMap(); + + context.paths = new HashSet(); + context.externalToServiceMap = new HashMap(); + context.locationsToRemove = new HashSet(); + + for (state in states) { + context.eventsById.putAll(state); + } + + for (entry in context.eventsById.entrySet()) { + processAndReturnEvent(context, entry.getKey()); + } + + def paths = new HashSet(); + + for(foundPath in context.paths) { + if (!context.locationsToRemove.contains(foundPath)) { + paths.add(foundPath); + } + } + + def response = new HashMap(); + response.paths = paths; + + def discoveredServices = new HashSet(); + + for(entry in context.externalToServiceMap.entrySet()) { + def map = new HashMap(); + map.from = entry.getKey(); + map.to = entry.getValue(); + discoveredServices.add(map); + } + response.discoveredServices = discoveredServices; + + return response;` + } + } + } + } + } + }; + + const serviceMapResponse = await client.search(serviceMapParams); + + const scriptResponse = serviceMapResponse.aggregations?.service_map.value as { + paths: ConnectionNode[][]; + discoveredServices: Array<{ + from: ExternalConnectionNode; + to: ServiceConnectionNode; + }>; + }; + + let paths = scriptResponse.paths; + + if (serviceName || environment) { + paths = paths.filter(path => { + return path.some(node => { + let matches = true; + if (serviceName) { + matches = + matches && + 'service.name' in node && + node['service.name'] === serviceName; + } + if (environment) { + matches = + matches && + 'service.environment' in node && + node['service.environment'] === environment; + } + return matches; + }); + }); + } + + const connections = uniq( + paths.flatMap(path => { + return path.reduce((conns, location, index) => { + const prev = path[index - 1]; + if (prev) { + return conns.concat({ + source: prev, + destination: location + }); + } + return conns; + }, [] as Connection[]); + }, [] as Connection[]), + (value, index, array) => { + return find(array, value); + } + ); + + return { + connections, + discoveredServices: scriptResponse.discoveredServices + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts new file mode 100644 index 0000000000000..6c4d540103cec --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { ESFilter } from '../../../typings/elasticsearch'; +import { rangeFilter } from '../helpers/range_filter'; +import { + PROCESSOR_EVENT, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_DURATION, + METRIC_SYSTEM_CPU_PERCENT, + METRIC_SYSTEM_FREE_MEMORY, + METRIC_SYSTEM_TOTAL_MEMORY, + SERVICE_NODE_NAME +} from '../../../common/elasticsearch_fieldnames'; +import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory'; +import { PromiseReturnType } from '../../../typings/common'; + +interface Options { + setup: Setup & SetupTimeRange; + environment?: string; + serviceName: string; +} + +interface TaskParameters { + setup: Setup; + minutes: number; + filter: ESFilter[]; +} + +export type ServiceNodeMetrics = PromiseReturnType< + typeof getServiceMapServiceNodeInfo +>; + +export async function getServiceMapServiceNodeInfo({ + serviceName, + environment, + setup +}: Options & { serviceName: string; environment?: string }) { + const { start, end } = setup; + + const filter: ESFilter[] = [ + { range: rangeFilter(start, end) }, + { term: { [SERVICE_NAME]: serviceName } }, + ...(environment + ? [{ term: { [SERVICE_ENVIRONMENT]: SERVICE_ENVIRONMENT } }] + : []) + ]; + + const minutes = Math.abs((end - start) / (1000 * 60)); + + const taskParams = { + setup, + minutes, + filter + }; + + const [ + errorMetrics, + transactionMetrics, + cpuMetrics, + memoryMetrics, + instanceMetrics + ] = await Promise.all([ + getErrorMetrics(taskParams), + getTransactionMetrics(taskParams), + getCpuMetrics(taskParams), + getMemoryMetrics(taskParams), + getNumInstances(taskParams) + ]); + + return { + ...errorMetrics, + ...transactionMetrics, + ...cpuMetrics, + ...memoryMetrics, + ...instanceMetrics + }; +} + +async function getErrorMetrics({ setup, minutes, filter }: TaskParameters) { + const { client, indices } = setup; + + const response = await client.search({ + index: indices['apm_oss.errorIndices'], + body: { + size: 0, + query: { + bool: { + filter: filter.concat({ + term: { + [PROCESSOR_EVENT]: 'error' + } + }) + } + }, + track_total_hits: true + } + }); + + return { + avgErrorsPerMinute: + response.hits.total.value > 0 ? response.hits.total.value / minutes : null + }; +} + +async function getTransactionMetrics({ + setup, + filter, + minutes +}: TaskParameters) { + const { indices, client } = setup; + + const response = await client.search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 1, + query: { + bool: { + filter: filter.concat({ + term: { + [PROCESSOR_EVENT]: 'transaction' + } + }) + } + }, + track_total_hits: true, + aggs: { + duration: { + avg: { + field: TRANSACTION_DURATION + } + } + } + } + }); + + return { + avgTransactionDuration: response.aggregations?.duration.value, + avgRequestsPerMinute: + response.hits.total.value > 0 ? response.hits.total.value / minutes : null + }; +} + +async function getCpuMetrics({ setup, filter }: TaskParameters) { + const { indices, client } = setup; + + const response = await client.search({ + index: indices['apm_oss.metricsIndices'], + body: { + size: 0, + query: { + bool: { + filter: filter.concat([ + { + term: { + [PROCESSOR_EVENT]: 'metric' + } + }, + { + exists: { + field: METRIC_SYSTEM_CPU_PERCENT + } + } + ]) + } + }, + aggs: { + avgCpuUsage: { + avg: { + field: METRIC_SYSTEM_CPU_PERCENT + } + } + } + } + }); + + return { + avgCpuUsage: response.aggregations?.avgCpuUsage.value + }; +} + +async function getMemoryMetrics({ setup, filter }: TaskParameters) { + const { client, indices } = setup; + const response = await client.search({ + index: indices['apm_oss.metricsIndices'], + body: { + query: { + bool: { + filter: filter.concat([ + { + term: { + [PROCESSOR_EVENT]: 'metric' + } + }, + { + exists: { + field: METRIC_SYSTEM_FREE_MEMORY + } + }, + { + exists: { + field: METRIC_SYSTEM_TOTAL_MEMORY + } + } + ]) + } + }, + aggs: { + avgMemoryUsage: { + avg: { + script: percentMemoryUsedScript + } + } + } + } + }); + + return { + avgMemoryUsage: response.aggregations?.avgMemoryUsage.value + }; +} + +async function getNumInstances({ setup, filter }: TaskParameters) { + const { client, indices } = setup; + const response = await client.search({ + index: indices['apm_oss.transactionIndices'], + body: { + query: { + bool: { + filter: filter.concat([ + { + term: { + [PROCESSOR_EVENT]: 'transaction' + } + }, + { + exists: { + field: SERVICE_NODE_NAME + } + }, + { + exists: { + field: METRIC_SYSTEM_TOTAL_MEMORY + } + } + ]) + } + }, + aggs: { + instances: { + cardinality: { + field: SERVICE_NODE_NAME + } + } + } + } + }); + + return { + numInstances: response.aggregations?.instances.value || 1 + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts new file mode 100644 index 0000000000000..acf113b426608 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { uniq, take, sortBy } from 'lodash'; +import { + Setup, + SetupUIFilters, + SetupTimeRange +} from '../helpers/setup_request'; +import { rangeFilter } from '../helpers/range_filter'; +import { ESFilter } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + SERVICE_ENVIRONMENT, + SPAN_TYPE, + SPAN_SUBTYPE, + DESTINATION_ADDRESS, + TRACE_ID +} from '../../../common/elasticsearch_fieldnames'; + +const MAX_TRACES_TO_INSPECT = 1000; + +export async function getTraceSampleIds({ + after, + serviceName, + environment, + setup +}: { + after?: string; + serviceName?: string; + environment?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const isTop = !after; + + const { start, end, client, indices, config } = setup; + + const rangeEnd = end; + const rangeStart = isTop + ? rangeEnd - config['xpack.apm.serviceMapInitialTimeRange'] + : start; + + const rangeQuery = { range: rangeFilter(rangeStart, rangeEnd) }; + + const query = { + bool: { + filter: [ + { + term: { + [PROCESSOR_EVENT]: 'span' + } + }, + { + exists: { + field: DESTINATION_ADDRESS + } + }, + rangeQuery + ] as ESFilter[] + } + } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; + + if (serviceName) { + query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); + } + + if (environment) { + query.bool.filter.push({ term: { [SERVICE_ENVIRONMENT]: environment } }); + } + + const afterObj = + after && after !== 'top' + ? { after: JSON.parse(Buffer.from(after, 'base64').toString()) } + : {}; + + const params = { + index: [indices['apm_oss.spanIndices']], + body: { + size: 0, + query, + aggs: { + connections: { + composite: { + size: 1000, + ...afterObj, + sources: [ + { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, + { + [SERVICE_ENVIRONMENT]: { + terms: { field: SERVICE_ENVIRONMENT, missing_bucket: true } + } + }, + { + [SPAN_TYPE]: { + terms: { field: SPAN_TYPE, missing_bucket: true } + } + }, + { + [SPAN_SUBTYPE]: { + terms: { field: SPAN_SUBTYPE, missing_bucket: true } + } + }, + { + [DESTINATION_ADDRESS]: { + terms: { field: DESTINATION_ADDRESS } + } + } + ] + }, + aggs: { + sample: { + sampler: { + shard_size: 30 + }, + aggs: { + trace_ids: { + terms: { + field: TRACE_ID, + execution_hint: 'map' as const, + // remove bias towards large traces by sorting on trace.id + // which will be random-esque + order: { + _key: 'desc' as const + } + } + } + } + } + } + } + } + } + }; + + const tracesSampleResponse = await client.search< + { trace: { id: string } }, + typeof params + >(params); + + let nextAfter: string | undefined; + + const receivedAfterKey = + tracesSampleResponse.aggregations?.connections.after_key; + + if (!after) { + nextAfter = 'top'; + } else if (receivedAfterKey) { + nextAfter = Buffer.from(JSON.stringify(receivedAfterKey)).toString( + 'base64' + ); + } + + // make sure at least one trace per composite/connection bucket + // is queried + const traceIdsWithPriority = + tracesSampleResponse.aggregations?.connections.buckets.flatMap(bucket => + bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ + traceId: sampleDocBucket.key as string, + priority: index + })) + ) || []; + + const traceIds = take( + uniq( + sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) + ), + MAX_TRACES_TO_INSPECT + ); + + return { + after: nextAfter, + traceIds + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index acd5dc119b737..bbf2a6882c3c7 100644 --- a/x-pack/legacy/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -137,7 +137,6 @@ Object { "events": Object { "terms": Object { "field": "processor.event", - "size": 2, }, }, }, diff --git a/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_services_items.ts b/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_services_items.ts index 8e578a839ae56..2f44b9231eae2 100644 --- a/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_services_items.ts +++ b/x-pack/legacy/plugins/apm/server/lib/services/get_services/get_services_items.ts @@ -44,7 +44,7 @@ export async function getServicesItems( terms: { field: SERVICE_AGENT_NAME, size: 1 } }, events: { - terms: { field: PROCESSOR_EVENT, size: 2 } + terms: { field: PROCESSOR_EVENT } }, environments: { terms: { field: SERVICE_ENVIRONMENT } diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.test.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.test.ts index f49c1e022a070..870b02fa7ba6d 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.test.ts @@ -70,13 +70,13 @@ describe('getTransactionBreakdown', () => { expect(response.kpis[0]).toEqual({ name: 'app', - color: '#00b3a4', + color: '#5bbaa0', percentage: 0.5408550899466306 }); expect(response.kpis[3]).toEqual({ name: 'postgresql', - color: '#490092', + color: '#9170b8', percentage: 0.047366859295002 }); }); diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts index e98842151da84..cf27d20c24360 100644 --- a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts @@ -58,7 +58,7 @@ import { uiFiltersEnvironmentsRoute } from './ui_filters'; import { createApi } from './create_api'; -import { serviceMapRoute } from './services'; +import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; const createApmApi = () => { const api = createApi() @@ -118,10 +118,13 @@ const createApmApi = () => { .add(transactionsLocalFiltersRoute) .add(serviceNodesLocalFiltersRoute) .add(uiFiltersEnvironmentsRoute) - .add(serviceMapRoute) // Transaction - .add(transactionByTraceIdRoute); + .add(transactionByTraceIdRoute) + + // Service map + .add(serviceMapRoute) + .add(serviceMapServiceNodeRoute); return api; }; diff --git a/x-pack/legacy/plugins/apm/server/routes/service_map.ts b/x-pack/legacy/plugins/apm/server/routes/service_map.ts new file mode 100644 index 0000000000000..584598805f8b3 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/service_map.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import Boom from 'boom'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; +import { getServiceMap } from '../lib/service_map/get_service_map'; +import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; + +export const serviceMapRoute = createRoute(() => ({ + path: '/api/apm/service-map', + params: { + query: t.intersection([ + t.partial({ environment: t.string, serviceName: t.string }), + uiFiltersRt, + rangeRt, + t.partial({ after: t.string }) + ]) + }, + handler: async ({ context, request }) => { + if (!context.config['xpack.apm.serviceMapEnabled']) { + throw Boom.notFound(); + } + const setup = await setupRequest(context, request); + const { + query: { serviceName, environment, after } + } = context.params; + return getServiceMap({ setup, serviceName, environment, after }); + } +})); + +export const serviceMapServiceNodeRoute = createRoute(() => ({ + path: `/api/apm/service-map/service/{serviceName}`, + params: { + path: t.type({ + serviceName: t.string + }), + query: t.intersection([ + rangeRt, + t.partial({ + environment: t.string + }) + ]) + }, + handler: async ({ context, request }) => { + if (!context.config['xpack.apm.serviceMapEnabled']) { + throw Boom.notFound(); + } + const setup = await setupRequest(context, request); + + const { + query: { environment }, + path: { serviceName } + } = context.params; + + return getServiceMapServiceNodeInfo({ + setup, + serviceName, + environment + }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 78cb092b85db6..18777183ea1de 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -5,7 +5,6 @@ */ import * as t from 'io-ts'; -import Boom from 'boom'; import { AgentName } from '../../typings/es_schemas/ui/fields/Agent'; import { createApmTelementry, @@ -18,7 +17,6 @@ import { getServiceTransactionTypes } from '../lib/services/get_service_transact import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getServiceMap } from '../lib/services/map'; import { getServiceAnnotations } from '../lib/services/annotations'; export const servicesRoute = createRoute(() => ({ @@ -87,19 +85,6 @@ export const serviceNodeMetadataRoute = createRoute(() => ({ } })); -export const serviceMapRoute = createRoute(() => ({ - path: '/api/apm/service-map', - params: { - query: rangeRt - }, - handler: async ({ context }) => { - if (context.config['xpack.apm.serviceMapEnabled']) { - return getServiceMap(); - } - return new Boom('Not found', { statusCode: 404 }); - } -})); - export const serviceAnnotationsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/annotations', params: { diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts index 74a9436d7a4bc..6d3620f11a87b 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts @@ -36,6 +36,19 @@ interface MetricsAggregationResponsePart { value: number | null; } +type GetCompositeKeys< + TAggregationOptionsMap extends AggregationOptionsMap +> = TAggregationOptionsMap extends { + composite: { sources: Array }; +} + ? keyof Source + : never; + +type CompositeOptionsSource = Record< + string, + { terms: { field: string; missing_bucket?: boolean } } | undefined +>; + export interface AggregationOptionsByType { terms: { field: string; @@ -97,6 +110,22 @@ export interface AggregationOptionsByType { buckets_path: BucketsPath; script?: Script; }; + composite: { + size?: number; + sources: CompositeOptionsSource[]; + after?: Record; + }; + diversified_sampler: { + shard_size?: number; + max_docs_per_value?: number; + } & ({ script: Script } | { field: string }); // TODO use MetricsAggregationOptions if possible + scripted_metric: { + params?: Record; + init_script?: Script; + map_script: Script; + combine_script: Script; + reduce_script: Script; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -229,6 +258,24 @@ interface AggregationResponsePart< value: number | null; } | undefined; + composite: { + after_key: Record, number>; + buckets: Array< + { + key: Record, number>; + doc_count: number; + } & BucketSubAggregationResponse< + TAggregationOptionsMap['aggs'], + TDocument + > + >; + }; + diversified_sampler: { + doc_count: number; + } & AggregationResponseMap; + scripted_metric: { + value: unknown; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts index eff39838bd957..064b684cf9aa6 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts @@ -56,6 +56,7 @@ export interface ESFilter { | string | string[] | number + | boolean | Record | ESFilter[]; }; diff --git a/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts b/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts index a1b1a8198bb35..993fac46ad7cb 100644 --- a/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts +++ b/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -interface IStackframeBase { - filename: string; +type IStackframeBase = { function?: string; library_frame?: boolean; exclude_from_grouping?: boolean; @@ -19,13 +18,13 @@ interface IStackframeBase { line: { number: number; }; -} +} & ({ classname: string } | { filename: string }); -export interface IStackframeWithLineContext extends IStackframeBase { +export type IStackframeWithLineContext = IStackframeBase & { line: { number: number; context: string; }; -} +}; export type IStackframe = IStackframeBase | IStackframeWithLineContext; diff --git a/x-pack/legacy/plugins/beats_management/public/components/inputs/input.tsx b/x-pack/legacy/plugins/beats_management/public/components/inputs/input.tsx index 0e07c2b4960b7..29cdcfccfc756 100644 --- a/x-pack/legacy/plugins/beats_management/public/components/inputs/input.tsx +++ b/x-pack/legacy/plugins/beats_management/public/components/inputs/input.tsx @@ -3,12 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFieldText, EuiFieldTextProps, EuiFormRow } from '@elastic/eui'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; import { CommonProps } from '@elastic/eui/src/components/common'; import { FormsyInputProps, withFormsy } from 'formsy-react'; import React, { Component, InputHTMLAttributes } from 'react'; -interface ComponentProps extends FormsyInputProps, CommonProps, EuiFieldTextProps { +interface ComponentProps + extends FormsyInputProps, + CommonProps, + Omit, 'onChange' | 'onBlur'> { instantValidation?: boolean; label: string; errorText: string; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts index 063e69d1d2141..e728ea25f5504 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts @@ -5,11 +5,11 @@ */ import { ExpressionType } from 'src/plugins/expressions/public'; -import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { EmbeddableInput } from '../../../../../../src/plugins/embeddable/public'; import { EmbeddableTypes } from './embeddable_types'; export const EmbeddableExpressionType = 'embeddable'; -export { EmbeddableTypes }; +export { EmbeddableTypes, EmbeddableInput }; export interface EmbeddableExpression { type: typeof EmbeddableExpressionType; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index 3669bd3e08201..8f5ad859d28ba 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -9,7 +9,7 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/visualize_embeddable/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes = { +export const EmbeddableTypes: { map: string; search: string; visualization: string } = { map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 097aef69d4b4c..48b50930d563e 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -32,6 +32,7 @@ import { image } from './image'; import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; +import { mapCenter } from './map_center'; import { mapColumn } from './mapColumn'; import { math } from './math'; import { metric } from './metric'; @@ -57,6 +58,7 @@ import { staticColumn } from './staticColumn'; import { string } from './string'; import { table } from './table'; import { tail } from './tail'; +import { timerange } from './time_range'; import { timefilter } from './timefilter'; import { timefilterControl } from './timefilterControl'; import { switchFn } from './switch'; @@ -91,6 +93,7 @@ export const functions = [ lt, lte, joinRows, + mapCenter, mapColumn, math, metric, @@ -118,6 +121,7 @@ export const functions = [ tail, timefilter, timefilterControl, + timerange, switchFn, caseFn, ]; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts new file mode 100644 index 0000000000000..21f9e9fe3148d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunction } from 'src/plugins/expressions/common'; +import { getFunctionHelp } from '../../../i18n/functions'; +import { MapCenter } from '../../../types'; + +interface Args { + lat: number; + lon: number; + zoom: number; +} + +export function mapCenter(): ExpressionFunction<'mapCenter', null, Args, MapCenter> { + const { help, args: argHelp } = getFunctionHelp().mapCenter; + return { + name: 'mapCenter', + help, + type: 'mapCenter', + context: { + types: ['null'], + }, + args: { + lat: { + types: ['number'], + required: true, + help: argHelp.lat, + }, + lon: { + types: ['number'], + required: true, + help: argHelp.lon, + }, + zoom: { + types: ['number'], + required: true, + help: argHelp.zoom, + }, + }, + fn: (context, args) => { + return { + type: 'mapCenter', + ...args, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index 25f035bbb6d8c..5b95886faa13d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; const filterContext = { and: [ @@ -24,20 +24,22 @@ describe('savedMap', () => { const fn = savedMap().fn; const args = { id: 'some-id', + center: null, + title: null, + timerange: null, + hideLayer: [], }; it('accepts null context', () => { const expression = fn(null, args, {}); expect(expression.input.filters).toEqual([]); - expect(expression.input.timeRange).toBeUndefined(); }); it('accepts filter context', () => { const expression = fn(filterContext, args, {}); - const embeddableFilters = buildEmbeddableFilters(filterContext.and); + const embeddableFilters = getQueryFilters(filterContext.and); - expect(expression.input.filters).toEqual(embeddableFilters.filters); - expect(expression.input.timeRange).toEqual(embeddableFilters.timeRange); + expect(expression.input.filters).toEqual(embeddableFilters); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index 460cb9c34efff..b6d88c06ed06d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -7,8 +7,8 @@ import { ExpressionFunction } from 'src/plugins/expressions/common/types'; import { TimeRange } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; +import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -19,19 +19,36 @@ import { esFilters } from '../../../../../../../src/plugins/data/public'; interface Arguments { id: string; + center: MapCenter | null; + hideLayer: string[]; + title: string | null; + timerange: TimeRangeArg | null; } // Map embeddable is missing proper typings, so type is just to document what we // are expecting to pass to the embeddable -interface SavedMapInput extends EmbeddableInput { +export type SavedMapInput = EmbeddableInput & { id: string; + isLayerTOCOpen: boolean; timeRange?: TimeRange; refreshConfig: { isPaused: boolean; interval: number; }; + hideFilterActions: true; filters: esFilters.Filter[]; -} + mapCenter?: { + lat: number; + lon: number; + zoom: number; + }; + hiddenLayers?: string[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; type Return = EmbeddableExpression; @@ -46,21 +63,56 @@ export function savedMap(): ExpressionFunction<'savedMap', Filter | null, Argume required: false, help: argHelp.id, }, + center: { + types: ['mapCenter'], + help: argHelp.center, + required: false, + }, + hideLayer: { + types: ['string'], + help: argHelp.hideLayer, + required: false, + multi: true, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, }, type: EmbeddableExpressionType, - fn: (context, { id }) => { + fn: (context, args) => { const filters = context ? context.and : []; + const center = args.center + ? { + lat: args.center.lat, + lon: args.center.lon, + zoom: args.center.zoom, + } + : undefined; + return { type: EmbeddableExpressionType, input: { - id, - ...buildEmbeddableFilters(filters), - + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, refreshConfig: { isPaused: false, interval: 0, }, + + mapCenter: center, + hideFilterActions: true, + title: args.title ? args.title : undefined, + isLayerTOCOpen: false, + hiddenLayers: args.hideLayer || [], }, embeddableType: EmbeddableTypes.map, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts new file mode 100644 index 0000000000000..716026279ccea --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunction } from 'src/plugins/expressions/common'; +import { getFunctionHelp } from '../../../i18n/functions'; +import { TimeRange } from '../../../types'; + +interface Args { + from: string; + to: string; +} + +export function timerange(): ExpressionFunction<'timerange', null, Args, TimeRange> { + const { help, args: argHelp } = getFunctionHelp().timerange; + return { + name: 'timerange', + help, + type: 'timerange', + context: { + types: ['null'], + }, + args: { + from: { + types: ['string'], + required: true, + help: argHelp.from, + }, + to: { + types: ['string'], + required: true, + help: argHelp.to, + }, + }, + fn: (context, args) => { + return { + type: 'timerange', + ...args, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx similarity index 74% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 5c7ef1a8c1799..8642ebd901bb4 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -10,32 +10,27 @@ import { I18nContext } from 'ui/i18n'; import { npStart } from 'ui/new_platform'; import { IEmbeddable, + EmbeddableFactory, EmbeddablePanel, EmbeddableFactoryNotFoundError, - EmbeddableInput, -} from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { start } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; -import { EmbeddableExpression } from '../expression_types/embeddable'; -import { RendererStrings } from '../../i18n'; +} from '../../../../../../../src/plugins/embeddable/public'; +import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; +import { EmbeddableExpression } from '../../expression_types/embeddable'; +import { RendererStrings } from '../../../i18n'; import { SavedObjectFinderProps, SavedObjectFinderUi, -} from '../../../../../../src/plugins/kibana_react/public'; +} from '../../../../../../../src/plugins/kibana_react/public'; const { embeddable: strings } = RendererStrings; +import { embeddableInputToExpression } from './embeddable_input_to_expression'; +import { EmbeddableInput } from '../../expression_types'; +import { RendererHandlers } from '../../../types'; const embeddablesRegistry: { [key: string]: IEmbeddable; } = {}; -interface Handlers { - setFilter: (text: string) => void; - getFilter: () => string | null; - done: () => void; - onResize: (fn: () => void) => void; - onDestroy: (fn: () => void) => void; -} - const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { const SavedObjectFinder = (props: SavedObjectFinderProps) => ( ({ render: async ( domNode: HTMLElement, { input, embeddableType }: EmbeddableExpression, - handlers: Handlers + handlers: RendererHandlers ) => { if (!embeddablesRegistry[input.id]) { const factory = Array.from(start.getEmbeddableFactories()).find( embeddableFactory => embeddableFactory.type === embeddableType - ); + ) as EmbeddableFactory; if (!factory) { handlers.done(); @@ -86,8 +81,13 @@ const embeddable = () => ({ } const embeddableObject = await factory.createFromSavedObject(input.id, input); + embeddablesRegistry[input.id] = embeddableObject; + ReactDOM.unmountComponentAtNode(domNode); + const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { + handlers.onEmbeddableInputChange(embeddableInputToExpression(updatedInput, embeddableType)); + }); ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); handlers.onResize(() => { @@ -97,7 +97,11 @@ const embeddable = () => ({ }); handlers.onDestroy(() => { + subscription.unsubscribe(); + handlers.onEmbeddableDestroyed(); + delete embeddablesRegistry[input.id]; + return ReactDOM.unmountComponentAtNode(domNode); }); } else { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts new file mode 100644 index 0000000000000..93d747537c34c --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { embeddableInputToExpression } from './embeddable_input_to_expression'; +import { SavedMapInput } from '../../functions/common/saved_map'; +import { EmbeddableTypes } from '../../expression_types'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseSavedMapInput = { + id: 'embeddableId', + filters: [], + isLayerTOCOpen: false, + refreshConfig: { + isPaused: true, + interval: 0, + }, + hideFilterActions: true as true, +}; + +describe('input to expression', () => { + describe('Map Embeddable', () => { + it('converts to a savedMap expression', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedMap'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('center'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + mapCenter: { + lat: 1, + lon: 2, + zoom: 3, + }, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + const centerExpression = ast.chain[0].arguments.center[0] as Ast; + + expect(centerExpression.chain[0].function).toBe('mapCenter'); + expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); + expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); + expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts new file mode 100644 index 0000000000000..a3cb53acebed2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; +import { SavedMapInput } from '../../functions/common/saved_map'; + +/* + Take the input from an embeddable and the type of embeddable and convert it into an expression +*/ +export function embeddableInputToExpression( + input: EmbeddableInput, + embeddableType: string +): string { + const expressionParts: string[] = []; + + if (embeddableType === EmbeddableTypes.map) { + const mapInput = input as SavedMapInput; + + expressionParts.push('savedMap'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (mapInput.mapCenter) { + expressionParts.push( + `center={mapCenter lat=${mapInput.mapCenter.lat} lon=${mapInput.mapCenter.lon} zoom=${mapInput.mapCenter.zoom}}` + ); + } + + if (mapInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${mapInput.timeRange.from}" to="${mapInput.timeRange.to}"}` + ); + } + + if (mapInput.hiddenLayers && mapInput.hiddenLayers.length) { + for (const layerId of mapInput.hiddenLayers) { + expressionParts.push(`hideLayer="${layerId}"`); + } + } + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js index 50fa6943fc74a..48364be06e539 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js @@ -7,7 +7,7 @@ import { advancedFilter } from './advanced_filter'; import { debug } from './debug'; import { dropdownFilter } from './dropdown_filter'; -import { embeddable } from './embeddable'; +import { embeddable } from './embeddable/embeddable'; import { error } from './error'; import { image } from './image'; import { markdown } from './markdown'; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js index 69f584af41556..d60dc13f0105b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/palette.js @@ -61,7 +61,9 @@ const PaletteArgInput = ({ onValueChange, argValue, renderError }) => { const palette = astToPalette(argValue); - return ; + return ( + + ); }; PaletteArgInput.propTypes = { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js index c056e7d1f2281..baa2127b03c3c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/shape.js @@ -20,6 +20,7 @@ const ShapeArgInput = ({ onValueChange, argValue, typeInstance }) => ( value={argValue} onChange={onValueChange} shapes={typeInstance.options.shapes} + ariaLabel={typeInstance.displayName} /> diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js index de19d3e29221b..bcad4678e0b6a 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/toggle.js @@ -12,7 +12,7 @@ import { ArgumentStrings } from '../../../i18n'; const { Toggle: strings } = ArgumentStrings; -const ToggleArgInput = ({ onValueChange, argValue, argId, renderError }) => { +const ToggleArgInput = ({ onValueChange, argValue, argId, renderError, typeInstance }) => { const handleChange = () => onValueChange(!argValue); if (typeof argValue !== 'boolean') { renderError(); @@ -26,6 +26,9 @@ const ToggleArgInput = ({ onValueChange, argValue, argId, renderError }) => { checked={argValue} onChange={handleChange} className="canvasArg__switch" + aria-label={typeInstance.displayName} + label="" + showLabel={false} /> ); diff --git a/x-pack/legacy/plugins/canvas/i18n/components.ts b/x-pack/legacy/plugins/canvas/i18n/components.ts index c898db7467b44..d0a9051d7af87 100644 --- a/x-pack/legacy/plugins/canvas/i18n/components.ts +++ b/x-pack/legacy/plugins/canvas/i18n/components.ts @@ -912,6 +912,10 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.textStylePicker.styleUnderlineOption', { defaultMessage: 'Underline', }), + getFontColorLabel: () => + i18n.translate('xpack.canvas.textStylePicker.fontColorLabel', { + defaultMessage: 'Font Color', + }), }, TimePicker: { getApplyButtonLabel: () => @@ -1007,7 +1011,11 @@ export const ComponentStrings = { getUSLetterButtonLabel: () => i18n.translate('xpack.canvas.workpadConfig.USLetterButtonLabel', { defaultMessage: 'US Letter', - description: 'This is referring to the dimentions of U.S. standard letter paper.', + description: 'This is referring to the dimensions of U.S. standard letter paper.', + }), + getBackgroundColorLabel: () => + i18n.translate('xpack.canvas.workpadConfig.backgroundColorLabel', { + defaultMessage: 'Background color', }), }, WorkpadCreate: { diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts new file mode 100644 index 0000000000000..3022ad07089d2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { mapCenter } from '../../../canvas_plugin_src/functions/common/map_center'; +import { FunctionHelp } from '../'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.mapCenterHelpText', { + defaultMessage: `Returns an object with the center coordinates and zoom level of the map`, + }), + args: { + lat: i18n.translate('xpack.canvas.functions.mapCenter.args.latHelpText', { + defaultMessage: `Latitude for the center of the map`, + }), + lon: i18n.translate('xpack.canvas.functions.savedMap.args.lonHelpText', { + defaultMessage: `Longitude for the center of the map`, + }), + zoom: i18n.translate('xpack.canvas.functions.savedMap.args.zoomHelpText', { + defaultMessage: `The zoom level of the map`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts index d01b77e1cfd51..53bcd481f185f 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts @@ -14,6 +14,20 @@ export const help: FunctionHelp> = { defaultMessage: `Returns an embeddable for a saved map object`, }), args: { - id: 'The id of the saved map object', + id: i18n.translate('xpack.canvas.functions.savedMap.args.idHelpText', { + defaultMessage: `The ID of the Saved Map Object`, + }), + center: i18n.translate('xpack.canvas.functions.savedMap.args.centerHelpText', { + defaultMessage: `The center and zoom level the map should have`, + }), + hideLayer: i18n.translate('xpack.canvas.functions.savedMap.args.hideLayer', { + defaultMessage: `The IDs of map layers that should be hidden`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedMap.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedMap.args.titleHelpText', { + defaultMessage: `The title for the map`, + }), }, }; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts new file mode 100644 index 0000000000000..476a9978800df --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { timerange } from '../../../canvas_plugin_src/functions/common/time_range'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.timerangeHelpText', { + defaultMessage: `An object that represents a span of time`, + }), + args: { + from: i18n.translate('xpack.canvas.functions.timerange.args.fromHelpText', { + defaultMessage: `The start of the time range`, + }), + to: i18n.translate('xpack.canvas.functions.timerange.args.toHelpText', { + defaultMessage: `The end of the time range`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index f6b3c451c6fbb..94d7e6f43326f 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -44,6 +44,7 @@ import { help as joinRows } from './dict/join_rows'; import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; +import { help as mapCenter } from './dict/map_center'; import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; import { help as math } from './dict/math'; @@ -75,6 +76,7 @@ import { help as tail } from './dict/tail'; import { help as timefilter } from './dict/timefilter'; import { help as timefilterControl } from './dict/timefilter_control'; import { help as timelion } from './dict/timelion'; +import { help as timerange } from './dict/time_range'; import { help as to } from './dict/to'; import { help as urlparam } from './dict/urlparam'; @@ -196,6 +198,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ location, lt, lte, + mapCenter, mapColumn, markdown, math, @@ -227,6 +230,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ timefilter, timefilterControl, timelion, + timerange, to, urlparam, }); diff --git a/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts b/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts index 261f67067cfaf..5ab6a908641de 100644 --- a/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts +++ b/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts @@ -42,7 +42,7 @@ export const getTemplateStrings = (): TemplateStringDict => ({ defaultMessage: 'Pitch', }), help: i18n.translate('xpack.canvas.templates.pitchHelp', { - defaultMessage: 'Branded presentation with large photos"', + defaultMessage: 'Branded presentation with large photos', }), }, Status: { diff --git a/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot b/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot index 454ef0a79d10a..35de0fb665be1 100644 --- a/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot @@ -25,11 +25,6 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` alt="Asset thumbnail" className="euiImage__img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1Ni4zMSA1Ni4zMSI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMDc4YTA7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPlBsYW5lIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNNDkuNTEsNDguOTMsNDEuMjYsMjIuNTIsNTMuNzYsMTBhNS4yOSw1LjI5LDAsMCwwLTcuNDgtNy40N2wtMTIuNSwxMi41TDcuMzgsNi43OUEuNy43LDAsMCwwLDYuNjksN0wxLjIsMTIuNDVhLjcuNywwLDAsMCwwLDFMMTkuODUsMjlsLTcuMjQsNy4yNC03Ljc0LS42YS43MS43MSwwLDAsMC0uNTMuMkwxLjIxLDM5YS42Ny42NywwLDAsMCwuMDgsMUw5LjQ1LDQ2bC4wNywwYy4xMS4xMy4yMi4yNi4zNC4zOHMuMjUuMjMuMzguMzRhLjM2LjM2LDAsMCwwLDAsLjA3TDE2LjMzLDU1YS42OC42OCwwLDAsMCwxLC4wN0wyMC40OSw1MmEuNjcuNjcsMCwwLDAsLjE5LS41NGwtLjU5LTcuNzQsNy4yNC03LjI0TDQyLjg1LDU1LjA2YS42OC42OCwwLDAsMCwxLDBsNS41LTUuNUEuNjYuNjYsMCwwLDAsNDkuNTEsNDguOTNaIi8+PC9nPjwvZz48L3N2Zz4=" - style={ - Object { - "backgroundImage": "url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1Ni4zMSA1Ni4zMSI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMDc4YTA7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPlBsYW5lIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNNDkuNTEsNDguOTMsNDEuMjYsMjIuNTIsNTMuNzYsMTBhNS4yOSw1LjI5LDAsMCwwLTcuNDgtNy40N2wtMTIuNSwxMi41TDcuMzgsNi43OUEuNy43LDAsMCwwLDYuNjksN0wxLjIsMTIuNDVhLjcuNywwLDAsMCwwLDFMMTkuODUsMjlsLTcuMjQsNy4yNC03Ljc0LS42YS43MS43MSwwLDAsMC0uNTMuMkwxLjIxLDM5YS42Ny42NywwLDAsMCwuMDgsMUw5LjQ1LDQ2bC4wNywwYy4xMS4xMy4yMi4yNi4zNC4zOHMuMjUuMjMuMzguMzRhLjM2LjM2LDAsMCwwLDAsLjA3TDE2LjMzLDU1YS42OC42OCwwLDAsMCwxLC4wN0wyMC40OSw1MmEuNjcuNjcsMCwwLDAsLjE5LS41NGwtLjU5LTcuNzQsNy4yNC03LjI0TDQyLjg1LDU1LjA2YS42OC42OCwwLDAsMCwxLDBsNS41LTUuNUEuNjYuNjYsMCwwLDAsNDkuNTEsNDguOTNaIi8+PC9nPjwvZz48L3N2Zz4=)", - } - } />
@@ -224,11 +219,6 @@ exports[`Storyshots components/Assets/Asset marker 1`] = ` alt="Asset thumbnail" className="euiImage__img" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzOC4zOSA1Ny41NyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMTliOGY7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPkxvY2F0aW9uIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTkuMTksMUExOC4xOSwxOC4xOSwwLDAsMCwyLjk0LDI3LjM2aDBhMTkuNTEsMTkuNTEsMCwwLDAsMSwxLjc4TDE5LjE5LDU1LjU3LDM0LjM4LDI5LjIxQTE4LjE5LDE4LjE5LDAsMCwwLDE5LjE5LDFabTAsMjMuMjlhNS41Myw1LjUzLDAsMSwxLDUuNTMtNS41M0E1LjUzLDUuNTMsMCwwLDEsMTkuMTksMjQuMjlaIi8+PC9nPjwvZz48L3N2Zz4=" - style={ - Object { - "backgroundImage": "url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzOC4zOSA1Ny41NyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMTliOGY7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPkxvY2F0aW9uIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTkuMTksMUExOC4xOSwxOC4xOSwwLDAsMCwyLjk0LDI3LjM2aDBhMTkuNTEsMTkuNTEsMCwwLDAsMSwxLjc4TDE5LjE5LDU1LjU3LDM0LjM4LDI5LjIxQTE4LjE5LDE4LjE5LDAsMCwwLDE5LjE5LDFabTAsMjMuMjlhNS41Myw1LjUzLDAsMSwxLDUuNTMtNS41M0E1LjUzLDUuNTMsMCwwLDEsMTkuMTksMjQuMjlaIi8+PC9nPjwvZz48L3N2Zz4=)", - } - } /> diff --git a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset.tsx b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset.tsx index 579470649582d..c1a2b0f0bf372 100644 --- a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset.tsx @@ -92,7 +92,6 @@ export const Asset: FunctionComponent = props => { url={props.asset.value} fullScreenIconColor="dark" alt={strings.getThumbnailAltText()} - style={{ backgroundImage: `url(${props.asset.value})` }} /> ); diff --git a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.scss b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.scss index 5b281129f533f..c8ab1323557bb 100644 --- a/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.scss +++ b/x-pack/legacy/plugins/canvas/public/components/asset_manager/asset_manager.scss @@ -46,18 +46,20 @@ margin: -$euiSizeS; margin-bottom: 0; font-size: 0; // eliminates any extra space around img + height: 164px; } .canvasAsset__img { - background-repeat: no-repeat; - background-position: center; - background-size: contain; + display: flex; + align-items: center; + justify-content: center; + + height: 100%; img { width: auto; max-width: 100%; - height: 164px; // nice default proportions for typical 4x3 images - opacity: 0; // only show the background image (which will properly keep proportions) + max-height: 164px; // nice default proportions for typical 4x3 images } } } diff --git a/x-pack/legacy/plugins/canvas/public/components/color_palette/__examples__/__snapshots__/color_palette.examples.storyshot b/x-pack/legacy/plugins/canvas/public/components/color_palette/__examples__/__snapshots__/color_palette.examples.storyshot index badbf96029f12..8610ed2f1b4a3 100644 --- a/x-pack/legacy/plugins/canvas/public/components/color_palette/__examples__/__snapshots__/color_palette.examples.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/color_palette/__examples__/__snapshots__/color_palette.examples.storyshot @@ -8,6 +8,7 @@ exports[`Storyshots components/Color/ColorPalette interactive 1`] = ` className="item-grid-row" > ); diff --git a/x-pack/legacy/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx b/x-pack/legacy/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx index 970f72da698ba..717ec6d0faecc 100644 --- a/x-pack/legacy/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx @@ -17,12 +17,13 @@ interface Props { }; onChange?: (key: string) => void; value?: string; + ariaLabel?: string; } -export const ShapePickerPopover = ({ shapes, onChange, value }: Props) => { +export const ShapePickerPopover = ({ shapes, onChange, value, ariaLabel }: Props) => { const button = (handleClick: (ev: MouseEvent) => void) => ( - + diff --git a/x-pack/legacy/plugins/canvas/public/components/text_style_picker/text_style_picker.js b/x-pack/legacy/plugins/canvas/public/components/text_style_picker/text_style_picker.js index 1a44181475091..179455e15b36e 100644 --- a/x-pack/legacy/plugins/canvas/public/components/text_style_picker/text_style_picker.js +++ b/x-pack/legacy/plugins/canvas/public/components/text_style_picker/text_style_picker.js @@ -127,6 +127,7 @@ export const TextStylePicker = ({ value={color} onChange={value => doChange('color', value)} colors={colors} + ariaLabel={strings.getFontColorLabel()} /> diff --git a/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx b/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx index da3475eceb18d..089f021ccdc32 100644 --- a/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx @@ -97,7 +97,7 @@ export const Toolbar = (props: Props) => { const trays = { pageManager: , - expression: !elementIsSelected ? null : , + expression: !elementIsSelected ? null : , }; return ( @@ -141,6 +141,7 @@ export const Toolbar = (props: Props) => { color="text" iconType="editorCodeBlock" onClick={() => showHideTray(TrayType.expression)} + data-test-subj="canvasExpressionEditorButton" > {strings.getEditorButtonLabel()} diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.tsx index 69401c89c79a5..c81f3e78efddd 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_color_picker/workpad_color_picker.tsx @@ -6,9 +6,18 @@ import React from 'react'; import { ColorPickerPopover, Props } from '../color_picker_popover'; +import { ComponentStrings } from '../../../i18n'; + +const { WorkpadConfig: strings } = ComponentStrings; export const WorkpadColorPicker = (props: Props) => { - return ; + return ( + + ); }; WorkpadColorPicker.propTypes = ColorPickerPopover.propTypes; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index 4ee3a65172a2e..b775524acf639 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -73,6 +73,32 @@ function closest(s) { return null; } +// If you interact with an embeddable panel, only the header should be draggable +// This function will determine if an element is an embeddable body or not +const isEmbeddableBody = element => { + const hasClosest = typeof element.closest === 'function'; + + if (hasClosest) { + return element.closest('.embeddable') && !element.closest('.embPanel__header'); + } else { + return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + } +}; + +// Some elements in an embeddable may be portaled out of the embeddable container. +// We do not want clicks on those to trigger drags, etc, in the workpad. This function +// will check to make sure the clicked item is actually in the container +const isInWorkpad = element => { + const hasClosest = typeof element.closest === 'function'; + const workpadContainerSelector = '.canvasWorkpadContainer'; + + if (hasClosest) { + return !!element.closest(workpadContainerSelector); + } else { + return !!closest.call(element, workpadContainerSelector); + } +}; + const componentLayoutState = ({ aeroStore, setAeroStore, @@ -209,6 +235,8 @@ export const InteractivePage = compose( withProps((...props) => ({ ...props, canDragElement: element => { + return !isEmbeddableBody(element) && isInWorkpad(element); + const hasClosest = typeof element.closest === 'function'; if (hasClosest) { diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js index cf07d1ed229f0..139d0f283bf1a 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js @@ -24,7 +24,10 @@ export const WorkpadTemplates = compose( cloneWorkpad: props => workpad => { workpad.id = getId('workpad'); workpad.name = `My Canvas Workpad - ${workpad.name}`; + // Remove unneeded fields workpad.tags = undefined; + workpad.displayName = undefined; + workpad.help = undefined; return workpadService .create(workpad) .then(() => props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 })) diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/color.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/color.js index 2a47150b4a1b9..8d756dd8111b1 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/color.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/color.js @@ -13,10 +13,15 @@ import { ArgTypesStrings } from '../../../i18n'; const { Color: strings } = ArgTypesStrings; -const ColorArgInput = ({ onValueChange, argValue, workpad }) => ( +const ColorArgInput = ({ onValueChange, argValue, workpad, typeInstance }) => ( - + ); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.examples.storyshot b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.examples.storyshot index 2915d3bfef57b..649d11cb2dbab 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.examples.storyshot +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/extended_template.examples.storyshot @@ -467,6 +467,7 @@ exports[`Storyshots arguments/ContainerStyle extended 1`] = ` className="euiPopover__anchor" > @@ -386,4 +396,4 @@ - + diff --git a/x-pack/legacy/plugins/graph/public/application.ts b/x-pack/legacy/plugins/graph/public/application.ts index 69bc789974632..8f486ab6ad51a 100644 --- a/x-pack/legacy/plugins/graph/public/application.ts +++ b/x-pack/legacy/plugins/graph/public/application.ts @@ -96,9 +96,8 @@ export const renderApp = ({ appBasePath, element, ...deps }: GraphDependencies) }; }; -const mainTemplate = (basePath: string) => `
+const mainTemplate = (basePath: string) => `
-
`; @@ -108,7 +107,7 @@ const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react', 'ui.boo function mountGraphApp(appBasePath: string, element: HTMLElement) { const mountpoint = document.createElement('div'); - mountpoint.setAttribute('style', 'height: 100%'); + mountpoint.setAttribute('class', 'kbnLocalApplicationWrapper'); // eslint-disable-next-line mountpoint.innerHTML = mainTemplate(appBasePath); // bootstrap angular into detached element and attach it later to diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 5ff7fc2e5da93..957a8f66907a1 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -16,6 +16,7 @@ import { FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; import { GraphStore } from '../state_management'; import { GuidancePanel } from './guidance_panel'; +import { GraphTitle } from './graph_title'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -52,6 +53,7 @@ export function GraphApp(props: GraphAppProps) { > <> + {props.isInitialized && }
diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx index 429eec19a47fa..0c099135f631d 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_icon.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { ICON_TYPES, palettes, EuiIcon } from '@elastic/eui'; +import { ICON_TYPES, euiPaletteColorBlind, EuiIcon } from '@elastic/eui'; function stringToNum(s: string) { return Array.from(s).reduce((acc, ch) => acc + ch.charCodeAt(0), 1); @@ -23,7 +23,7 @@ function getIconForDataType(dataType: string) { export function getColorForDataType(type: string) { const iconType = getIconForDataType(type); - const { colors } = palettes.euiPaletteColorBlind; + const colors = euiPaletteColorBlind(); const colorIndex = stringToNum(iconType) % colors.length; return colors[colorIndex]; } diff --git a/x-pack/legacy/plugins/graph/public/components/graph_title.tsx b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx new file mode 100644 index 0000000000000..8151900da0c07 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { EuiScreenReaderOnly } from '@elastic/eui'; +import React from 'react'; + +import { GraphState, metaDataSelector } from '../state_management'; + +interface GraphTitleProps { + title: string; +} + +/** + * Component showing the title of the current workspace as a heading visible for screen readers + */ +export const GraphTitle = connect((state: GraphState) => ({ + title: metaDataSelector(state).title, +}))(({ title }: GraphTitleProps) => ( + +

{title}

+
+)); diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss index f1c332eba1aa8..e1423b794dcd3 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss @@ -15,16 +15,10 @@ position: relative; padding-left: $euiSizeXL; margin-bottom: $euiSizeL; - - button { - // make buttons wrap lines like regular text - display: contents; - } } .gphGuidancePanel__item--disabled { color: $euiColorDarkShade; - pointer-events: none; button { color: $euiColorDarkShade !important; diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx index 5fae9720db39a..f34b82d6bb1a3 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -13,6 +13,7 @@ import { EuiText, EuiLink, EuiCallOut, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; @@ -53,6 +54,7 @@ function ListItem({ 'gphGuidancePanel__item--disabled': state === 'disabled', })} aria-disabled={state === 'disabled'} + aria-current={state === 'active' ? 'step' : undefined} > {state !== 'disabled' && ( -

+

{i18n.translate('xpack.graph.guidancePanel.title', { defaultMessage: 'Three steps to your graph', })} @@ -104,7 +106,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { -
    +
      {i18n.translate( @@ -116,7 +118,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { - + {i18n.translate('xpack.graph.guidancePanel.fieldsItem.fieldsButtonLabel', { defaultMessage: 'Add fields.', })} @@ -128,7 +130,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { defaultMessage="Enter a query in the search bar to start exploring. Don't know where to start? {topTerms}." values={{ topTerms: ( - + {i18n.translate('xpack.graph.guidancePanel.nodesItem.topTermsButtonLabel', { defaultMessage: 'Graph the top terms', })} @@ -137,7 +139,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { }} /> -
+
@@ -157,7 +159,15 @@ function GuidancePanelComponent(props: GuidancePanelProps) { title={i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { defaultMessage: 'No data source', })} + heading="h1" > + +

+ {i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { + defaultMessage: 'No data source', + })} +

+

+

-

+ } />
@@ -88,12 +89,12 @@ function getNoItemsMessage( +

-

+ } body={ diff --git a/x-pack/legacy/plugins/graph/public/helpers/style_choices.ts b/x-pack/legacy/plugins/graph/public/helpers/style_choices.ts index 855818886ab6f..46fec39bfce06 100644 --- a/x-pack/legacy/plugins/graph/public/helpers/style_choices.ts +++ b/x-pack/legacy/plugins/graph/public/helpers/style_choices.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; // @ts-ignore -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; export interface FontawesomeIcon { class: string; @@ -255,4 +255,4 @@ urlTemplateIconChoices.forEach(icon => { urlTemplateIconChoicesByClass[icon.class] = icon; }); -export const colorChoices = palettes.euiPaletteColorBlind.colors; +export const colorChoices = euiPaletteColorBlind(); diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts index 1861479f85f18..efef3d246ac98 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts @@ -161,7 +161,7 @@ describe('deserialize', () => { }, Object { "aggregatable": true, - "color": "#CE0060", + "color": "#D36086", "hopSize": 5, "icon": Object { "class": "fa-folder-open-o", diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts index 1749421277719..d9ca9a96ffe51 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './log_entry_categories'; +export * from './log_entry_category_datasets'; export * from './log_entry_rate'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts new file mode 100644 index 0000000000000..66823c25237ac --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH = + '/api/infra/log_analysis/results/log_entry_categories'; + +/** + * request + */ + +const logEntryCategoriesHistogramParametersRT = rt.type({ + id: rt.string, + timeRange: timeRangeRT, + bucketCount: rt.number, +}); + +export type LogEntryCategoriesHistogramParameters = rt.TypeOf< + typeof logEntryCategoriesHistogramParametersRT +>; + +export const getLogEntryCategoriesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the number of categories to fetch + categoryCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the categories from + timeRange: timeRangeRT, + // a list of histograms to create + histograms: rt.array(logEntryCategoriesHistogramParametersRT), + }), + rt.partial({ + // the datasets to filter for (optional, unfiltered if not present) + datasets: rt.array(rt.string), + }), + ]), +}); + +export type GetLogEntryCategoriesRequestPayload = rt.TypeOf< + typeof getLogEntryCategoriesRequestPayloadRT +>; + +/** + * response + */ + +export const logEntryCategoryHistogramBucketRT = rt.type({ + startTime: rt.number, + bucketDuration: rt.number, + logEntryCount: rt.number, +}); + +export type LogEntryCategoryHistogramBucket = rt.TypeOf; + +export const logEntryCategoryHistogramRT = rt.type({ + histogramId: rt.string, + buckets: rt.array(logEntryCategoryHistogramBucketRT), +}); + +export type LogEntryCategoryHistogram = rt.TypeOf; + +export const logEntryCategoryRT = rt.type({ + categoryId: rt.number, + datasets: rt.array(rt.string), + histograms: rt.array(logEntryCategoryHistogramRT), + logEntryCount: rt.number, + maximumAnomalyScore: rt.number, + regularExpression: rt.string, +}); + +export type LogEntryCategory = rt.TypeOf; + +export const getLogEntryCategoriesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + categories: rt.array(logEntryCategoryRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoriesSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoriesSuccessReponsePayloadRT +>; + +export const getLogEntryCategoriesResponsePayloadRT = rt.union([ + getLogEntryCategoriesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoriesReponsePayload = rt.TypeOf< + typeof getLogEntryCategoriesResponsePayloadRT +>; diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts new file mode 100644 index 0000000000000..934d1052fa29f --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH = + '/api/infra/log_analysis/results/log_entry_category_datasets'; + +/** + * request + */ + +export const getLogEntryCategoryDatasetsRequestPayloadRT = rt.type({ + data: rt.type({ + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the category datasets from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryCategoryDatasetsRequestPayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsRequestPayloadRT +>; + +/** + * response + */ + +export const getLogEntryCategoryDatasetsSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + datasets: rt.array(rt.string), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoryDatasetsSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsSuccessReponsePayloadRT +>; + +export const getLogEntryCategoryDatasetsResponsePayloadRT = rt.union([ + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoryDatasetsReponsePayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsResponsePayloadRT +>; diff --git a/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts index 1047ca2f2a01a..caeb1914cb8a2 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts @@ -7,3 +7,4 @@ export * from './errors'; export * from './metric_statistics'; export * from './time_range'; +export * from './timing'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts new file mode 100644 index 0000000000000..a208921c03d6f --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { tracingSpanRT } from '../../performance_tracing'; + +export const routeTimingMetadataRT = rt.type({ + spans: rt.array(tracingSpanRT), +}); diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/index.ts b/x-pack/legacy/plugins/infra/common/log_analysis/index.ts index 79913f829191d..22137e63ab7e7 100644 --- a/x-pack/legacy/plugins/infra/common/log_analysis/index.ts +++ b/x-pack/legacy/plugins/infra/common/log_analysis/index.ts @@ -5,4 +5,7 @@ */ export * from './log_analysis'; +export * from './log_analysis_results'; +export * from './log_entry_rate_analysis'; +export * from './log_entry_categories_analysis'; export * from './job_parameters'; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts index 4a6f20d549799..9b2f1a55eb8c1 100644 --- a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts @@ -4,14 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as rt from 'io-ts'; - -export const jobTypeRT = rt.keyof({ - 'log-entry-rate': null, -}); - -export type JobType = rt.TypeOf; - // combines and abstracts job and datafeed status export type JobStatus = | 'unknown' diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts new file mode 100644 index 0000000000000..1dcd4a10fc4e3 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ML_SEVERITY_SCORES = { + warning: 3, + minor: 25, + major: 50, + critical: 75, +}; + +export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; + +export const ML_SEVERITY_COLORS = { + critical: 'rgb(228, 72, 72)', + major: 'rgb(229, 113, 0)', + minor: 'rgb(255, 221, 0)', + warning: 'rgb(125, 180, 226)', +}; + +export const getSeverityCategoryForScore = ( + score: number +): MLSeverityScoreCategories | undefined => { + if (score >= ML_SEVERITY_SCORES.critical) { + return 'critical'; + } else if (score >= ML_SEVERITY_SCORES.major) { + return 'major'; + } else if (score >= ML_SEVERITY_SCORES.minor) { + return 'minor'; + } else if (score >= ML_SEVERITY_SCORES.warning) { + return 'warning'; + } else { + // Category is too low to include + return undefined; + } +}; + +export const formatAnomalyScore = (score: number) => { + return Math.round(score); +}; + +export const getFriendlyNameForPartitionId = (partitionId: string) => { + return partitionId !== '' ? partitionId : 'unknown'; +}; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts new file mode 100644 index 0000000000000..0957126ee52e3 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryCategoriesJobTypeRT = rt.keyof({ + 'log-entry-categories-count': null, +}); + +export type LogEntryCategoriesJobType = rt.TypeOf; + +export const logEntryCategoriesJobTypes: LogEntryCategoriesJobType[] = [ + 'log-entry-categories-count', +]; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts new file mode 100644 index 0000000000000..7fd668dc4ebce --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryRateJobTypeRT = rt.keyof({ + 'log-entry-rate': null, +}); + +export type LogEntryRateJobType = rt.TypeOf; + +export const logEntryRateJobTypes: LogEntryRateJobType[] = ['log-entry-rate']; diff --git a/x-pack/legacy/plugins/infra/common/performance_tracing.ts b/x-pack/legacy/plugins/infra/common/performance_tracing.ts new file mode 100644 index 0000000000000..3e96f3c19d06d --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/performance_tracing.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import uuid from 'uuid'; + +export const tracingSpanRT = rt.type({ + duration: rt.number, + id: rt.string, + name: rt.string, + start: rt.number, +}); + +export type TracingSpan = rt.TypeOf; + +export type ActiveTrace = (endTime?: number) => TracingSpan; + +export const startTracingSpan = (name: string): ActiveTrace => { + const initialState: TracingSpan = { + duration: Number.POSITIVE_INFINITY, + id: uuid.v4(), + name, + start: Date.now(), + }; + + return (endTime: number = Date.now()) => ({ + ...initialState, + duration: endTime - initialState.start, + }); +}; diff --git a/x-pack/legacy/plugins/infra/common/runtime_types.ts b/x-pack/legacy/plugins/infra/common/runtime_types.ts index 297743f9b3456..d5b858df38def 100644 --- a/x-pack/legacy/plugins/infra/common/runtime_types.ts +++ b/x-pack/legacy/plugins/infra/common/runtime_types.ts @@ -4,11 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Errors } from 'io-ts'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { Errors, Type } from 'io-ts'; import { failure } from 'io-ts/lib/PathReporter'; +type ErrorFactory = (message: string) => Error; + export const createPlainError = (message: string) => new Error(message); -export const throwErrors = (createError: (message: string) => Error) => (errors: Errors) => { +export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => { throw createError(failure(errors).join('\n')); }; + +export const decodeOrThrow = ( + runtimeType: Type, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); diff --git a/x-pack/legacy/plugins/infra/public/apps/start_app.tsx b/x-pack/legacy/plugins/infra/public/apps/start_app.tsx index 8ccb051724ede..dbdc827478a45 100644 --- a/x-pack/legacy/plugins/infra/public/apps/start_app.tsx +++ b/x-pack/legacy/plugins/infra/public/apps/start_app.tsx @@ -27,6 +27,7 @@ import { KibanaContextProvider, } from '../../../../../../src/plugins/kibana_react/public'; import { ROOT_ELEMENT_ID } from '../app'; + // NP_TODO: Type plugins export async function startApp(libs: InfraFrontendLibs, core: CoreStart, plugins: any) { const history = createHashHistory(); diff --git a/x-pack/legacy/plugins/infra/public/components/beta_badge.tsx b/x-pack/legacy/plugins/infra/public/components/beta_badge.tsx new file mode 100644 index 0000000000000..5d5770af1a41e --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/beta_badge.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiBetaBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const BetaBadge: React.FunctionComponent = () => ( + +); +const betaBadgeLabel = i18n.translate('xpack.infra.common.tabBetaBadgeLabel', { + defaultMessage: 'Beta', +}); + +const betaBadgeTooltipContent = i18n.translate('xpack.infra.common.tabBetaBadgeTooltipContent', { + defaultMessage: + 'This feature is under active development. Extra functionality is coming, and some functionality may change.', +}); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index 06229a26afd19..e954cf21229ee 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts @@ -5,3 +5,4 @@ */ export * from './log_analysis_job_problem_indicator'; +export * from './recreate_job_button'; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx index 018c5f5e0570d..8a16d819e12c2 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx @@ -17,13 +17,22 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; }> = ({ jobStatus, setupStatus, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate }) => { - if (jobStatus === 'stopped') { + if (isStopped(jobStatus)) { return ; - } else if (setupStatus === 'skippedButUpdatable') { + } else if (isUpdatable(setupStatus)) { return ; - } else if (setupStatus === 'skippedButReconfigurable') { + } else if (isReconfigurable(setupStatus)) { return ; } return null; // no problem to indicate }; + +const isStopped = (jobStatus: JobStatus) => jobStatus === 'stopped'; + +const isUpdatable = (setupStatus: SetupStatus) => setupStatus === 'skippedButUpdatable'; + +const isReconfigurable = (setupStatus: SetupStatus) => setupStatus === 'skippedButReconfigurable'; + +export const jobHasProblem = (jobStatus: JobStatus, setupStatus: SetupStatus) => + isStopped(jobStatus) || isUpdatable(setupStatus) || isReconfigurable(setupStatus); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx new file mode 100644 index 0000000000000..74e8d197ef455 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, PropsOf } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const RecreateJobButton: React.FunctionComponent> = props => ( + + + +); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx index b95054bbd6a9b..5b872d4ee5147 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx @@ -5,8 +5,9 @@ */ import React from 'react'; -import { EuiCallOut, EuiButton } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; + +import { RecreateJobButton } from './recreate_job_button'; export const RecreateJobCallout: React.FC<{ onRecreateMlJob: () => void; @@ -14,11 +15,6 @@ export const RecreateJobCallout: React.FC<{ }> = ({ children, onRecreateMlJob, title }) => (

{children}

- - - +
); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx new file mode 100644 index 0000000000000..7fcdcc89a633a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const FirstUseCallout = () => { + return ( + +

+ {i18n.translate('xpack.infra.logs.analysis.onboardingSuccessContent', { + defaultMessage: + 'Please allow a few minutes for our machine learning robots to begin collecting data.', + })} +

+
+ ); +}; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts index 8a4ceb70252a3..a3139124e6c9f 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts @@ -5,3 +5,4 @@ */ export * from './analyze_in_ml_button'; +export * from './first_use_callout'; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx index 3334e565f70f7..89f8d77bd5f63 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx @@ -13,11 +13,12 @@ import { LoadingOverlayWrapper } from '../../../loading_overlay_wrapper'; import { ValidatedIndex, ValidationIndicesUIError } from './validation'; export const AnalysisSetupIndicesForm: React.FunctionComponent<{ + disabled?: boolean; indices: ValidatedIndex[]; isValidating: boolean; onChangeSelectedIndices: (selectedIndices: ValidatedIndex[]) => void; valid: boolean; -}> = ({ indices, isValidating, onChangeSelectedIndices, valid }) => { +}> = ({ disabled = false, indices, isValidating, onChangeSelectedIndices, valid }) => { const handleCheckboxChange = useCallback( (event: React.ChangeEvent) => { onChangeSelectedIndices( @@ -40,7 +41,7 @@ export const AnalysisSetupIndicesForm: React.FunctionComponent<{ label={{index.name}} onChange={handleCheckboxChange} checked={index.validity === 'valid' && index.isSelected} - disabled={index.validity === 'invalid'} + disabled={disabled || index.validity === 'invalid'} /> ); @@ -52,7 +53,7 @@ export const AnalysisSetupIndicesForm: React.FunctionComponent<{
); }), - [handleCheckboxChange, indices] + [disabled, handleCheckboxChange, indices] ); return ( diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_timerange_form.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_timerange_form.tsx index f45d274169497..4319f844b1dcc 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_timerange_form.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_timerange_form.tsx @@ -46,11 +46,12 @@ function selectedDateToParam(selectedDate: Moment | null) { } export const AnalysisSetupTimerangeForm: React.FunctionComponent<{ + disabled?: boolean; setStartTime: (startTime: number | undefined) => void; setEndTime: (endTime: number | undefined) => void; startTime: number | undefined; endTime: number | undefined; -}> = ({ setStartTime, setEndTime, startTime, endTime }) => { +}> = ({ disabled = false, setStartTime, setEndTime, startTime, endTime }) => { const now = useMemo(() => moment(), []); const selectedEndTimeIsToday = !endTime || moment(endTime).isSame(now, 'day'); const startTimeValue = useMemo(() => { @@ -86,9 +87,11 @@ export const AnalysisSetupTimerangeForm: React.FunctionComponent<{ > setStartTime(undefined) } : undefined} + clear={startTime && !disabled ? { onClick: () => setStartTime(undefined) } : undefined} + isDisabled={disabled} > setStartTime(selectedDateToParam(date))} @@ -107,9 +110,11 @@ export const AnalysisSetupTimerangeForm: React.FunctionComponent<{ > setEndTime(undefined) } : undefined} + clear={endTime && !disabled ? { onClick: () => setEndTime(undefined) } : undefined} + isDisabled={disabled} > setEndTime(selectedDateToParam(date))} diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx index 2494b802cdb5b..de20dd12c17bd 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx @@ -8,8 +8,9 @@ import { EuiSpacer, EuiForm, EuiCallOut } from '@elastic/eui'; import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { SetupStatus } from '../../../../../common/log_analysis'; import { AnalysisSetupIndicesForm } from './analysis_setup_indices_form'; import { AnalysisSetupTimerangeForm } from './analysis_setup_timerange_form'; import { ValidatedIndex, ValidationIndicesUIError } from './validation'; @@ -21,6 +22,7 @@ interface InitialConfigurationStepProps { endTime: number | undefined; isValidating: boolean; validatedIndices: ValidatedIndex[]; + setupStatus: SetupStatus; setValidatedIndices: (selectedIndices: ValidatedIndex[]) => void; validationErrors?: ValidationIndicesUIError[]; } @@ -39,20 +41,25 @@ export const InitialConfigurationStep: React.FunctionComponent { + const disabled = useMemo(() => !editableFormStatus.includes(setupStatus), [setupStatus]); + return ( <> ({ diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx index 505966e62e45f..750894fd0188b 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx @@ -44,6 +44,9 @@ export const MetricsExplorerGroupBy = ({ options, onChange, fields }: Props) => placeholder={i18n.translate('xpack.infra.metricsExplorer.groupByLabel', { defaultMessage: 'Everything', })} + aria-label={i18n.translate('xpack.infra.metricsExplorer.groupByAriaLabel', { + defaultMessage: 'Graph per', + })} fullWidth singleSelection={true} selectedOptions={(options.groupBy && [{ label: options.groupBy }]) || []} diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx index f1957c1fa91a7..7114217920998 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/kuery_bar.tsx @@ -48,18 +48,21 @@ export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, value } fields: derivedIndexPattern.fields.filter(field => isDisplayable(field)), }; + const placeholder = i18n.translate('xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder', { + defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)', + }); + return ( {({ isLoadingSuggestions, loadSuggestions, suggestions }) => ( diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx index 7a8b22467ccd8..0010fce7efa49 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx @@ -71,6 +71,7 @@ export const MetricsExplorerMetrics = ({ options, onChange, fields, autoFocus = return ( { } } -const tabBetaBadgeLabel = i18n.translate('xpack.infra.common.tabBetaBadgeLabel', { - defaultMessage: 'Beta', -}); - -const tabBetaBadgeTooltipContent = i18n.translate('xpack.infra.common.tabBetaBadgeTooltipContent', { - defaultMessage: - 'This feature is under active development. Extra functionality is coming, and some functionality may change.', -}); - -export const TabBetaBadge = euiStyled(EuiBetaBadge).attrs({ - 'aria-label': tabBetaBadgeLabel, - label: tabBetaBadgeLabel, - tooltipContent: tabBetaBadgeTooltipContent, -})` - margin-left: 4px; - vertical-align: baseline; -`; - const TabContainer = euiStyled.div` .euiLink { color: inherit !important; diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts b/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts index 41c155e185c3a..a067285026e33 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts @@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf ( jobSummaries .filter(jobSummary => jobSummary.id === jobId) .every( - jobSummary => - jobSummary.fullJob && - jobSummary.fullJob.custom_settings && - jobSummary.fullJob.custom_settings.job_revision && - jobSummary.fullJob.custom_settings.job_revision >= currentRevision + jobSummary => (jobSummary?.fullJob?.custom_settings?.job_revision ?? 0) >= currentRevision ); const isJobConfigurationConsistent = ( diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx index f38f066b5323f..505878f0239dc 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/index.tsx @@ -11,7 +11,7 @@ import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { Header } from '../../components/header'; -import { RoutedTabs, TabBetaBadge } from '../../components/navigation/routed_tabs'; +import { RoutedTabs } from '../../components/navigation/routed_tabs'; import { ColumnarPage } from '../../components/page'; import { SourceLoadingPage } from '../../components/source_loading_page'; import { SourceErrorPage } from '../../components/source_error_page'; @@ -41,22 +41,12 @@ export const LogsPage = ({ match }: RouteComponentProps) => { }; const logRateTab = { - title: ( - <> - {logRateTabTitle} - - - ), + title: logRateTabTitle, path: `${match.path}/log-rate`, }; const logCategoriesTab = { - title: ( - <> - {logCategoriesTabTitle} - - - ), + title: logCategoriesTabTitle, path: `${match.path}/log-categories`, }; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts index 5910dc54dfc90..be7547f2e74cb 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts @@ -8,6 +8,8 @@ import { bucketSpan, categoriesMessageField, getJobId, + LogEntryCategoriesJobType, + logEntryCategoriesJobTypes, partitionField, } from '../../../../common/log_analysis'; @@ -21,22 +23,19 @@ import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; -const jobTypes = ['log-entry-categories-count']; const moduleId = 'logs_ui_categories'; -type JobType = typeof jobTypes[0]; - const getJobIds = (spaceId: string, sourceId: string) => - jobTypes.reduce( + logEntryCategoriesJobTypes.reduce( (accumulatedJobIds, jobType) => ({ ...accumulatedJobIds, [jobType]: getJobId(spaceId, sourceId, jobType), }), - {} as Record + {} as Record ); const getJobSummary = async (spaceId: string, sourceId: string) => { - const response = await callJobsSummaryAPI(spaceId, sourceId, jobTypes); + const response = await callJobsSummaryAPI(spaceId, sourceId, logEntryCategoriesJobTypes); const jobIds = Object.values(getJobIds(spaceId, sourceId)); return response.filter(jobSummary => jobIds.includes(jobSummary.id)); @@ -83,7 +82,7 @@ const setUpModule = async ( }; const cleanUpModule = async (spaceId: string, sourceId: string) => { - return await cleanUpJobsAndDatafeeds(spaceId, sourceId, jobTypes); + return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryCategoriesJobTypes); }; const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { @@ -103,9 +102,9 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; -export const logEntryCategoriesModule: ModuleDescriptor = { +export const logEntryCategoriesModule: ModuleDescriptor = { moduleId, - jobTypes, + jobTypes: logEntryCategoriesJobTypes, bucketSpan, getJobIds, getJobSummary, diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index 9a50acf622ee1..cc59d73055796 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -14,6 +14,7 @@ import { MlUnavailablePrompt, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisCapabilities } from '../../../containers/logs/log_analysis'; +import { LogEntryCategoriesResultsContent } from './page_results_content'; import { LogEntryCategoriesSetupContent } from './page_setup_content'; import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; @@ -44,8 +45,7 @@ export const LogEntryCategoriesPageContent = () => { } else if (setupStatus === 'unknown') { return ; } else if (isSetupStatusWithResults(setupStatus)) { - return null; - // return ; + return ; } else { return ; } diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx new file mode 100644 index 0000000000000..a810ce447d369 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import datemath from '@elastic/datemath'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import euiStyled from '../../../../../../common/eui_styled_components'; +import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { + LogAnalysisJobProblemIndicator, + jobHasProblem, +} from '../../../components/logging/log_analysis_job_status'; +import { FirstUseCallout } from '../../../components/logging/log_analysis_results'; +import { useInterval } from '../../../hooks/use_interval'; +import { useTrackPageview } from '../../../hooks/use_track_metric'; +import { TopCategoriesSection } from './sections/top_categories'; +import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; +import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; +import { + StringTimeRange, + useLogEntryCategoriesResultsUrlState, +} from './use_log_entry_categories_results_url_state'; + +const JOB_STATUS_POLLING_INTERVAL = 30000; + +export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => { + useTrackPageview({ app: 'infra_logs', path: 'log_entry_categories_results' }); + useTrackPageview({ app: 'infra_logs', path: 'log_entry_categories_results', delay: 15000 }); + + const { + fetchJobStatus, + jobStatus, + setupStatus, + viewSetupForReconfiguration, + viewSetupForUpdate, + jobIds, + sourceConfiguration: { sourceId }, + } = useLogEntryCategoriesModuleContext(); + + const { + timeRange: selectedTimeRange, + setTimeRange: setSelectedTimeRange, + autoRefresh, + setAutoRefresh, + } = useLogEntryCategoriesResultsUrlState(); + + const [categoryQueryTimeRange, setCategoryQueryTimeRange] = useState<{ + lastChangedTime: number; + timeRange: TimeRange; + }>(() => ({ + lastChangedTime: Date.now(), + timeRange: stringToNumericTimeRange(selectedTimeRange), + })); + + const [categoryQueryDatasets, setCategoryQueryDatasets] = useState([]); + + const { services } = useKibana<{}>(); + + const showLoadDataErrorNotification = useCallback( + (error: Error) => { + // eslint-disable-next-line no-unused-expressions + services.notifications?.toasts.addError(error, { + title: loadDataErrorTitle, + }); + }, + [services.notifications] + ); + + const { + getLogEntryCategoryDatasets, + getTopLogEntryCategories, + isLoadingLogEntryCategoryDatasets, + isLoadingTopLogEntryCategories, + logEntryCategoryDatasets, + topLogEntryCategories, + } = useLogEntryCategoriesResults({ + categoriesCount: 25, + endTime: categoryQueryTimeRange.timeRange.endTime, + filteredDatasets: categoryQueryDatasets, + onGetTopLogEntryCategoriesError: showLoadDataErrorNotification, + sourceId, + startTime: categoryQueryTimeRange.timeRange.startTime, + }); + + const handleQueryTimeRangeChange = useCallback( + ({ start: startTime, end: endTime }: { start: string; end: string }) => { + setCategoryQueryTimeRange(previousQueryParameters => ({ + ...previousQueryParameters, + timeRange: stringToNumericTimeRange({ startTime, endTime }), + lastChangedTime: Date.now(), + })); + }, + [setCategoryQueryTimeRange] + ); + + const handleSelectedTimeRangeChange = useCallback( + (selectedTime: { start: string; end: string; isInvalid: boolean }) => { + if (selectedTime.isInvalid) { + return; + } + setSelectedTimeRange({ + startTime: selectedTime.start, + endTime: selectedTime.end, + }); + handleQueryTimeRangeChange(selectedTime); + }, + [setSelectedTimeRange, handleQueryTimeRangeChange] + ); + + const handleAutoRefreshChange = useCallback( + ({ isPaused, refreshInterval: interval }: { isPaused: boolean; refreshInterval: number }) => { + setAutoRefresh({ + isPaused, + interval, + }); + }, + [setAutoRefresh] + ); + + const isFirstUse = useMemo(() => setupStatus === 'hiddenAfterSuccess', [setupStatus]); + + const hasResults = useMemo(() => topLogEntryCategories.length > 0, [ + topLogEntryCategories.length, + ]); + + useEffect(() => { + getTopLogEntryCategories(); + }, [getTopLogEntryCategories, categoryQueryDatasets, categoryQueryTimeRange.lastChangedTime]); + + useEffect(() => { + getLogEntryCategoryDatasets(); + }, [getLogEntryCategoryDatasets, categoryQueryTimeRange.lastChangedTime]); + + useInterval(() => { + fetchJobStatus(); + }, JOB_STATUS_POLLING_INTERVAL); + + useInterval( + () => { + handleQueryTimeRangeChange({ + start: selectedTimeRange.startTime, + end: selectedTimeRange.endTime, + }); + }, + autoRefresh.isPaused ? null : autoRefresh.interval + ); + + return ( + + + + + + + + + + + + + {jobHasProblem(jobStatus['log-entry-categories-count'], setupStatus) ? ( + + + + ) : null} + {isFirstUse && !hasResults ? ( + + + + ) : null} + + + + + + + + ); +}; + +const stringToNumericTimeRange = (timeRange: StringTimeRange): TimeRange => ({ + startTime: moment( + datemath.parse(timeRange.startTime, { + momentInstance: moment, + }) + ).valueOf(), + endTime: moment( + datemath.parse(timeRange.endTime, { + momentInstance: moment, + roundUp: true, + }) + ).valueOf(), +}); + +// This is needed due to the flex-basis: 100% !important; rule that +// kicks in on small screens via media queries breaking when using direction="column" +export const ResultsContentPage = euiStyled(EuiPage)` + flex: 1 0 0%; + flex-direction: column; + + .euiFlexGroup--responsive > .euiFlexItem { + flex-basis: auto !important; + } +`; + +const loadDataErrorTitle = i18n.translate( + 'xpack.infra.logs.logEntryCategories.loadDataErrorTitle', + { + defaultMessage: 'Failed to load category data', + } +); diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx index f0e90cb7ccc05..ac902029f938e 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx @@ -8,6 +8,7 @@ import { EuiSpacer, EuiSteps, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; +import { BetaBadge } from '../../../components/beta_badge'; import { createInitialConfigurationStep, createProcessStep, @@ -47,6 +48,7 @@ export const LogEntryCategoriesSetupContent: React.FunctionComponent = () => { endTime, isValidating, validatedIndices, + setupStatus, setValidatedIndices, validationErrors, }), @@ -82,7 +84,8 @@ export const LogEntryCategoriesSetupContent: React.FunctionComponent = () => { + />{' '} + diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx new file mode 100644 index 0000000000000..e50231316fb5a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHealth } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { + formatAnomalyScore, + getSeverityCategoryForScore, + ML_SEVERITY_COLORS, +} from '../../../../../../common/log_analysis'; + +export const AnomalySeverityIndicator: React.FunctionComponent<{ + anomalyScore: number; +}> = ({ anomalyScore }) => { + const severityColor = useMemo(() => getColorForAnomalyScore(anomalyScore), [anomalyScore]); + + return {formatAnomalyScore(anomalyScore)}; +}; + +const getColorForAnomalyScore = (anomalyScore: number) => { + const severityCategory = getSeverityCategoryForScore(anomalyScore); + + if (severityCategory != null && severityCategory in ML_SEVERITY_COLORS) { + return ML_SEVERITY_COLORS[severityCategory]; + } else { + return 'subdued'; + } +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx new file mode 100644 index 0000000000000..5c8b18528cae6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { memo } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; + +export const RegularExpressionRepresentation: React.FunctionComponent<{ + maximumSegmentCount?: number; + regularExpression: string; +}> = memo(({ maximumSegmentCount = 30, regularExpression }) => { + const segments = regularExpression.split(collapsedRegularExpressionCharacters); + + return ( + + {segments + .slice(0, maximumSegmentCount) + .map((segment, segmentIndex) => [ + segmentIndex > 0 ? ( + + ) : null, + + {segment.replace(escapedRegularExpressionCharacters, '$1')} + , + ])} + {segments.length > maximumSegmentCount ? ( + + … + + ) : null} + + ); +}); + +const CategoryPattern = euiStyled.span` + font-family: ${props => props.theme.eui.euiCodeFontFamily}; + word-break: break-all; +`; + +const CategoryPatternWildcard = euiStyled.span` + color: ${props => props.theme.eui.euiColorMediumShade}; +`; + +const CategoryPatternSegment = euiStyled.span` + font-weight: bold; +`; + +const collapsedRegularExpressionCharacters = /\.[+*]\??/g; + +const escapedRegularExpressionCharacters = /\\([\\^$*+?.()\[\]])/g; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx new file mode 100644 index 0000000000000..c30612f54be00 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; + +export const DatasetsList: React.FunctionComponent<{ + datasets: string[]; +}> = ({ datasets }) => ( +
    + {datasets.sort().map(dataset => { + const datasetLabel = getFriendlyNameForPartitionId(dataset); + return
  • {datasetLabel}
  • ; + })} +
+); diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx new file mode 100644 index 0000000000000..9c22caa4b3465 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo } from 'react'; + +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; + +type DatasetOptionProps = EuiComboBoxOptionProps; + +export const DatasetsSelector: React.FunctionComponent<{ + availableDatasets: string[]; + isLoading?: boolean; + onChangeDatasetSelection: (datasets: string[]) => void; + selectedDatasets: string[]; +}> = ({ availableDatasets, isLoading = false, onChangeDatasetSelection, selectedDatasets }) => { + const options = useMemo( + () => + availableDatasets.map(dataset => ({ + value: dataset, + label: getFriendlyNameForPartitionId(dataset), + })), + [availableDatasets] + ); + + const selectedOptions = useMemo( + () => options.filter(({ value }) => value != null && selectedDatasets.includes(value)), + [options, selectedDatasets] + ); + + const handleChange = useCallback( + (newSelectedOptions: DatasetOptionProps[]) => + onChangeDatasetSelection(newSelectedOptions.map(({ value }) => value).filter(isDefined)), + [onChangeDatasetSelection] + ); + + return ( + + ); +}; + +const datasetFilterPlaceholder = i18n.translate( + 'xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder', + { + defaultMessage: 'Filter by datasets', + } +); + +const isDefined = (value: Value): value is NonNullable => value != null; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts similarity index 79% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js rename to x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts index aec48f4c626ca..e699bbf956f94 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { interval$, SelectInterval } from './select_interval'; +export * from './top_categories_section'; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx new file mode 100644 index 0000000000000..7a29ea9aa0ebc --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; + +import { LogEntryCategoryHistogram } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { SingleMetricComparison } from './single_metric_comparison'; +import { SingleMetricSparkline } from './single_metric_sparkline'; + +export const LogEntryCountSparkline: React.FunctionComponent<{ + currentCount: number; + histograms: LogEntryCategoryHistogram[]; + timeRange: TimeRange; +}> = ({ currentCount, histograms, timeRange }) => { + const metric = useMemo( + () => + histograms + .find(histogram => histogram.histogramId === 'history') + ?.buckets?.map(({ startTime: timestamp, logEntryCount: value }) => ({ + timestamp, + value, + })) ?? [], + [histograms] + ); + const referenceCount = useMemo( + () => + histograms.find(histogram => histogram.histogramId === 'reference')?.buckets?.[0] + ?.logEntryCount ?? 0, + [histograms] + ); + + const overallTimeRange = useMemo( + () => ({ + endTime: timeRange.endTime, + startTime: timeRange.startTime - (timeRange.endTime - timeRange.startTime), + }), + [timeRange.endTime, timeRange.startTime] + ); + + return ( + <> + + + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx new file mode 100644 index 0000000000000..1352afb60a505 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon, EuiTextColor } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; + +export const SingleMetricComparison: React.FunctionComponent<{ + currentValue: number; + previousValue: number; +}> = ({ currentValue, previousValue }) => { + const changeFactor = currentValue / previousValue - 1; + + if (changeFactor < 0) { + return ( + + + {formatPercentage(changeFactor)} + + ); + } else if (changeFactor > 0 && Number.isFinite(changeFactor)) { + return ( + + + {formatPercentage(changeFactor)} + + ); + } else if (changeFactor > 0 && !Number.isFinite(changeFactor)) { + return ( + + + {newCategoryTrendLabel} + + ); + } + + return null; +}; + +const formatPercentage = (value: number) => numeral(value).format('+0,0 %'); + +const newCategoryTrendLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.newCategoryTrendLabel', + { + defaultMessage: 'new', + } +); + +const NoWrapSpan = euiStyled.span` + white-space: nowrap; +`; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx new file mode 100644 index 0000000000000..5fb8e3380f23f --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { Chart, Settings, AreaSeries } from '@elastic/charts'; +import { + EUI_CHARTS_THEME_LIGHT, + EUI_SPARKLINE_THEME_PARTIAL, + EUI_CHARTS_THEME_DARK, +} from '@elastic/eui/dist/eui_charts_theme'; + +import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; +import { TimeRange } from '../../../../../../common/http_api/shared'; + +interface TimeSeriesPoint { + timestamp: number; + value: number; +} + +const timestampAccessor = 'timestamp'; +const valueAccessor = ['value']; +const sparklineSize = { + height: 20, + width: 100, +}; + +export const SingleMetricSparkline: React.FunctionComponent<{ + metric: TimeSeriesPoint[]; + timeRange: TimeRange; +}> = ({ metric, timeRange }) => { + const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); + + const theme = useMemo( + () => [ + // localThemeOverride, + EUI_SPARKLINE_THEME_PARTIAL, + isDarkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme, + ], + [isDarkMode] + ); + + const xDomain = useMemo( + () => ({ + max: timeRange.endTime, + min: timeRange.startTime, + }), + [timeRange] + ); + + return ( + + + + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx new file mode 100644 index 0000000000000..962b506536253 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import { LogEntryCategory } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { BetaBadge } from '../../../../../components/beta_badge'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; +import { RecreateJobButton } from '../../../../../components/logging/log_analysis_job_status'; +import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; +import { DatasetsSelector } from './datasets_selector'; +import { TopCategoriesTable } from './top_categories_table'; + +export const TopCategoriesSection: React.FunctionComponent<{ + availableDatasets: string[]; + isLoadingDatasets?: boolean; + isLoadingTopCategories?: boolean; + jobId: string; + onChangeDatasetSelection: (datasets: string[]) => void; + onRequestRecreateMlJob: () => void; + selectedDatasets: string[]; + timeRange: TimeRange; + topCategories: LogEntryCategory[]; +}> = ({ + availableDatasets, + isLoadingDatasets = false, + isLoadingTopCategories = false, + jobId, + onChangeDatasetSelection, + onRequestRecreateMlJob, + selectedDatasets, + timeRange, + topCategories, +}) => { + return ( + <> + + + +

+ {title} +

+
+
+ + + + + + +
+ + + + } + > + + + + ); +}; + +const title = i18n.translate('xpack.infra.logs.logEntryCategories.topCategoriesSectionTitle', { + defaultMessage: 'Log message categories', +}); + +const loadingAriaLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.topCategoriesSectionLoadingAriaLabel', + { defaultMessage: 'Loading message categories' } +); + +const LoadingOverlayContent = () => ; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx new file mode 100644 index 0000000000000..3d20aef03ff15 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; +import { + LogEntryCategory, + LogEntryCategoryHistogram, +} from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; +import { RegularExpressionRepresentation } from './category_expression'; +import { DatasetsList } from './datasets_list'; +import { LogEntryCountSparkline } from './log_entry_count_sparkline'; + +export const TopCategoriesTable = euiStyled( + ({ + className, + timeRange, + topCategories, + }: { + className?: string; + timeRange: TimeRange; + topCategories: LogEntryCategory[]; + }) => { + const columns = useMemo(() => createColumns(timeRange), [timeRange]); + + return ( + + ); + } +)` + &.euiTableRow--topAligned .euiTableRowCell { + vertical-align: top; + } +`; + +const createColumns = (timeRange: TimeRange): Array> => [ + { + align: 'right', + field: 'logEntryCount', + name: i18n.translate('xpack.infra.logs.logEntryCategories.countColumnTitle', { + defaultMessage: 'Message count', + }), + render: (logEntryCount: number) => { + return numeral(logEntryCount).format('0,0'); + }, + width: '120px', + }, + { + field: 'histograms', + name: i18n.translate('xpack.infra.logs.logEntryCategories.trendColumnTitle', { + defaultMessage: 'Trend', + }), + render: (histograms: LogEntryCategoryHistogram[], item) => { + return ( + + ); + }, + width: '220px', + }, + { + field: 'regularExpression', + name: i18n.translate('xpack.infra.logs.logEntryCategories.categoryColumnTitle', { + defaultMessage: 'Category', + }), + truncateText: true, + render: (regularExpression: string) => ( + + ), + }, + { + field: 'datasets', + name: i18n.translate('xpack.infra.logs.logEntryCategories.datasetColumnTitle', { + defaultMessage: 'Datasets', + }), + render: (datasets: string[]) => , + width: '200px', + }, + { + align: 'right', + field: 'maximumAnomalyScore', + name: i18n.translate('xpack.infra.logs.logEntryCategories.maximumAnomalyScoreColumnTitle', { + defaultMessage: 'Maximum anomaly score', + }), + render: (maximumAnomalyScore: number) => ( + + ), + width: '160px', + }, +]; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts new file mode 100644 index 0000000000000..942ded4230e97 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from 'ui/new_platform'; + +import { + getLogEntryCategoryDatasetsRequestPayloadRT, + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetLogEntryCategoryDatasetsAPI = async ( + sourceId: string, + startTime: number, + endTime: number +) => { + const response = await npStart.core.http.fetch( + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, + { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoryDatasetsRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + } + ); + + return pipe( + getLogEntryCategoryDatasetsSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts new file mode 100644 index 0000000000000..35d6f1ec4f893 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from 'ui/new_platform'; + +import { + getLogEntryCategoriesRequestPayloadRT, + getLogEntryCategoriesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetTopLogEntryCategoriesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets?: string[] +) => { + const intervalDuration = endTime - startTime; + + const response = await npStart.core.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoriesRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + categoryCount, + datasets, + histograms: [ + { + id: 'history', + timeRange: { + startTime: startTime - intervalDuration, + endTime, + }, + bucketCount: 10, + }, + { + id: 'reference', + timeRange: { + startTime: startTime - intervalDuration, + endTime: startTime, + }, + bucketCount: 1, + }, + ], + }, + }) + ), + }); + + return pipe( + getLogEntryCategoriesSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts new file mode 100644 index 0000000000000..2282582dc2bd6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useState } from 'react'; + +import { + GetLogEntryCategoriesSuccessResponsePayload, + GetLogEntryCategoryDatasetsSuccessResponsePayload, +} from '../../../../common/http_api/log_analysis'; +import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; +import { callGetTopLogEntryCategoriesAPI } from './service_calls/get_top_log_entry_categories'; +import { callGetLogEntryCategoryDatasetsAPI } from './service_calls/get_log_entry_category_datasets'; + +type TopLogEntryCategories = GetLogEntryCategoriesSuccessResponsePayload['data']['categories']; +type LogEntryCategoryDatasets = GetLogEntryCategoryDatasetsSuccessResponsePayload['data']['datasets']; + +export const useLogEntryCategoriesResults = ({ + categoriesCount, + filteredDatasets: filteredDatasets, + endTime, + onGetLogEntryCategoryDatasetsError, + onGetTopLogEntryCategoriesError, + sourceId, + startTime, +}: { + categoriesCount: number; + filteredDatasets: string[]; + endTime: number; + onGetLogEntryCategoryDatasetsError?: (error: Error) => void; + onGetTopLogEntryCategoriesError?: (error: Error) => void; + sourceId: string; + startTime: number; +}) => { + const [topLogEntryCategories, setTopLogEntryCategories] = useState([]); + const [logEntryCategoryDatasets, setLogEntryCategoryDatasets] = useState< + LogEntryCategoryDatasets + >([]); + + const [getTopLogEntryCategoriesRequest, getTopLogEntryCategories] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetTopLogEntryCategoriesAPI( + sourceId, + startTime, + endTime, + categoriesCount, + filteredDatasets + ); + }, + onResolve: ({ data: { categories } }) => { + setTopLogEntryCategories(categories); + }, + onReject: error => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetTopLogEntryCategoriesError + ) { + onGetTopLogEntryCategoriesError(error); + } + }, + }, + [categoriesCount, endTime, filteredDatasets, sourceId, startTime] + ); + + const [getLogEntryCategoryDatasetsRequest, getLogEntryCategoryDatasets] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryCategoryDatasetsAPI(sourceId, startTime, endTime); + }, + onResolve: ({ data: { datasets } }) => { + setLogEntryCategoryDatasets(datasets); + }, + onReject: error => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetLogEntryCategoryDatasetsError + ) { + onGetLogEntryCategoryDatasetsError(error); + } + }, + }, + [categoriesCount, endTime, sourceId, startTime] + ); + + const isLoadingTopLogEntryCategories = useMemo( + () => getTopLogEntryCategoriesRequest.state === 'pending', + [getTopLogEntryCategoriesRequest.state] + ); + + const isLoadingLogEntryCategoryDatasets = useMemo( + () => getLogEntryCategoryDatasetsRequest.state === 'pending', + [getLogEntryCategoryDatasetsRequest.state] + ); + + const isLoading = useMemo( + () => isLoadingTopLogEntryCategories || isLoadingLogEntryCategoryDatasets, + [isLoadingLogEntryCategoryDatasets, isLoadingTopLogEntryCategories] + ); + + return { + getLogEntryCategoryDatasets, + getTopLogEntryCategories, + isLoading, + isLoadingLogEntryCategoryDatasets, + isLoadingTopLogEntryCategories, + logEntryCategoryDatasets, + topLogEntryCategories, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx new file mode 100644 index 0000000000000..bf30f96e4b741 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; + +import { useUrlState } from '../../../utils/use_url_state'; + +const autoRefreshRT = rt.union([ + rt.type({ + interval: rt.number, + isPaused: rt.boolean, + }), + rt.undefined, +]); + +export const stringTimeRangeRT = rt.type({ + startTime: rt.string, + endTime: rt.string, +}); +export type StringTimeRange = rt.TypeOf; + +const urlTimeRangeRT = rt.union([stringTimeRangeRT, rt.undefined]); + +const TIME_RANGE_URL_STATE_KEY = 'timeRange'; +const AUTOREFRESH_URL_STATE_KEY = 'autoRefresh'; + +export const useLogEntryCategoriesResultsUrlState = () => { + const [timeRange, setTimeRange] = useUrlState({ + defaultState: { + startTime: 'now-2w', + endTime: 'now', + }, + decodeUrlState: (value: unknown) => + pipe(urlTimeRangeRT.decode(value), fold(constant(undefined), identity)), + encodeUrlState: urlTimeRangeRT.encode, + urlStateKey: TIME_RANGE_URL_STATE_KEY, + writeDefaultState: true, + }); + + const [autoRefresh, setAutoRefresh] = useUrlState({ + defaultState: { + isPaused: false, + interval: 60000, + }, + decodeUrlState: (value: unknown) => + pipe(autoRefreshRT.decode(value), fold(constant(undefined), identity)), + encodeUrlState: autoRefreshRT.encode, + urlStateKey: AUTOREFRESH_URL_STATE_KEY, + writeDefaultState: true, + }); + + return { + timeRange, + setTimeRange, + autoRefresh, + setAutoRefresh, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx deleted file mode 100644 index 1ab9356a69e2a..0000000000000 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; - -export const FirstUseCallout = () => { - return ( - <> - -

- {i18n.translate('xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent', { - defaultMessage: - 'Please allow a few minutes for our machine learning robots to begin collecting data.', - })} -

-
- - - ); -}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts index 52be313264335..52ba3101dbc38 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { bucketSpan, getJobId, partitionField } from '../../../../common/log_analysis'; +import { + bucketSpan, + getJobId, + LogEntryRateJobType, + logEntryRateJobTypes, + partitionField, +} from '../../../../common/log_analysis'; import { ModuleDescriptor, @@ -16,22 +22,19 @@ import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; -const jobTypes = ['log-entry-rate']; const moduleId = 'logs_ui_analysis'; -type JobType = typeof jobTypes[0]; - const getJobIds = (spaceId: string, sourceId: string) => - jobTypes.reduce( + logEntryRateJobTypes.reduce( (accumulatedJobIds, jobType) => ({ ...accumulatedJobIds, [jobType]: getJobId(spaceId, sourceId, jobType), }), - {} as Record + {} as Record ); const getJobSummary = async (spaceId: string, sourceId: string) => { - const response = await callJobsSummaryAPI(spaceId, sourceId, jobTypes); + const response = await callJobsSummaryAPI(spaceId, sourceId, logEntryRateJobTypes); const jobIds = Object.values(getJobIds(spaceId, sourceId)); return response.filter(jobSummary => jobIds.includes(jobSummary.id)); @@ -78,7 +81,7 @@ const setUpModule = async ( }; const cleanUpModule = async (spaceId: string, sourceId: string) => { - return await cleanUpJobsAndDatafeeds(spaceId, sourceId, jobTypes); + return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryRateJobTypes); }; const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { @@ -94,9 +97,9 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; -export const logEntryRateModule: ModuleDescriptor = { +export const logEntryRateModule: ModuleDescriptor = { moduleId, - jobTypes, + jobTypes: logEntryRateJobTypes, bucketSpan, getJobIds, getJobSummary, diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index b6ab8acdea5b2..fd77cc8dd7173 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiPage, EuiPanel, + EuiSpacer, EuiSuperDatePicker, EuiText, } from '@elastic/eui'; @@ -26,7 +27,6 @@ import { LoadingOverlayWrapper } from '../../../components/loading_overlay_wrapp import { useInterval } from '../../../hooks/use_interval'; import { useTrackPageview } from '../../../hooks/use_track_metric'; import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; -import { FirstUseCallout } from './first_use'; import { AnomaliesResults } from './sections/anomalies'; import { LogRateResults } from './sections/log_rate'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; @@ -35,6 +35,7 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; +import { FirstUseCallout } from '../../../components/logging/log_analysis_results'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -154,7 +155,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - + {logEntryRate ? ( @@ -195,8 +196,13 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - - {isFirstUse && !hasResults ? : null} + + {isFirstUse && !hasResults ? ( + <> + + + + ) : null} { - + { // This is needed due to the flex-basis: 100% !important; rule that // kicks in on small screens via media queries breaking when using direction="column" export const ResultsContentPage = euiStyled(EuiPage)` + flex: 1 0 0%; + .euiFlexGroup--responsive > .euiFlexItem { flex-basis: auto !important; } diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx index 7e90cf29072e1..13574f589a111 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx @@ -8,6 +8,7 @@ import { EuiSpacer, EuiSteps, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; +import { BetaBadge } from '../../../components/beta_badge'; import { createInitialConfigurationStep, createProcessStep, @@ -47,6 +48,7 @@ export const LogEntryRateSetupContent: React.FunctionComponent = () => { endTime, isValidating, validatedIndices, + setupStatus, setValidatedIndices, validationErrors, }), @@ -82,7 +84,8 @@ export const LogEntryRateSetupContent: React.FunctionComponent = () => { + />{' '} + diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index a75e6c50ab03f..1a3a7d9e2b572 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -22,8 +22,11 @@ import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { + MLSeverityScoreCategories, + ML_SEVERITY_COLORS, +} from '../../../../../../common/log_analysis'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; -import { MLSeverityScoreCategories } from '../helpers/data_formatters'; export const AnomaliesChart: React.FunctionComponent<{ chartId: string; @@ -109,19 +112,19 @@ interface SeverityConfig { const severityConfigs: Record = { warning: { id: `anomalies-warning`, - style: { fill: 'rgb(125, 180, 226)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.warning, opacity: 0.7 }, }, minor: { id: `anomalies-minor`, - style: { fill: 'rgb(255, 221, 0)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.minor, opacity: 0.7 }, }, major: { id: `anomalies-major`, - style: { fill: 'rgb(229, 113, 0)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.major, opacity: 0.7 }, }, critical: { id: `anomalies-critical`, - style: { fill: 'rgb(228, 72, 72)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.critical, opacity: 0.7 }, }, }; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index e5e719c2d69f6..0dc52d2762765 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -12,7 +12,6 @@ import { EuiStat, EuiTitle, EuiLoadingSpinner, - EuiButton, } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -21,16 +20,18 @@ import React, { useMemo } from 'react'; import euiStyled from '../../../../../../../../common/eui_styled_components'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { JobStatus, SetupStatus } from '../../../../../../common/log_analysis'; +import { formatAnomalyScore, JobStatus, SetupStatus } from '../../../../../../common/log_analysis'; import { - formatAnomalyScore, getAnnotationsForAll, getLogEntryRateCombinedSeries, getTopAnomalyScoreAcrossAllPartitions, } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; -import { LogAnalysisJobProblemIndicator } from '../../../../../components/logging/log_analysis_job_status'; +import { + LogAnalysisJobProblemIndicator, + RecreateJobButton, +} from '../../../../../components/logging/log_analysis_job_status'; import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; @@ -92,16 +93,14 @@ export const AnomaliesResults: React.FunctionComponent<{ return ( <> - +

{title}

- - Recreate jobs - + diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 45893315c7361..3e86b45fadfdd 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState, useCallback } from 'react'; import { EuiBasicTable, EuiButtonIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo, useState } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { + formatAnomalyScore, + getFriendlyNameForPartitionId, +} from '../../../../../../common/log_analysis'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; -import { formatAnomalyScore, getFriendlyNameForPartitionId } from '../helpers/data_formatters'; -import euiStyled from '../../../../../../../../common/eui_styled_components'; interface TableItem { id: string; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx index f9b85fc4e20c2..e8e4c18e7420c 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx @@ -7,17 +7,14 @@ import { RectAnnotationDatum } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { + formatAnomalyScore, + getFriendlyNameForPartitionId, + getSeverityCategoryForScore, + MLSeverityScoreCategories, +} from '../../../../../../common/log_analysis'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -const ML_SEVERITY_SCORES = { - warning: 3, - minor: 25, - major: 50, - critical: 75, -}; - -export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; - export const getLogEntryRatePartitionedSeries = (results: LogEntryRateResults) => { return results.histogramBuckets.reduce>( (buckets, bucket) => { @@ -182,26 +179,3 @@ export const getTopAnomalyScoreAcrossAllPartitions = (results: LogEntryRateResul ); return Math.max(...allTopScores); }; - -const getSeverityCategoryForScore = (score: number): MLSeverityScoreCategories | undefined => { - if (score >= ML_SEVERITY_SCORES.critical) { - return 'critical'; - } else if (score >= ML_SEVERITY_SCORES.major) { - return 'major'; - } else if (score >= ML_SEVERITY_SCORES.minor) { - return 'minor'; - } else if (score >= ML_SEVERITY_SCORES.warning) { - return 'warning'; - } else { - // Category is too low to include - return undefined; - } -}; - -export const formatAnomalyScore = (score: number) => { - return Math.round(score); -}; - -export const getFriendlyNameForPartitionId = (partitionId: string) => { - return partitionId !== '' ? partitionId : 'unknown'; -}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx index a11dc9d4d607a..3da025d90119f 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; -import { LogEntryRateResults as Results } from '../../use_log_entry_rate_results'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { LogEntryRateBarChart } from './bar_chart'; -import { getLogEntryRatePartitionedSeries } from '../helpers/data_formatters'; +import { BetaBadge } from '../../../../../components/beta_badge'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; +import { LogEntryRateResults as Results } from '../../use_log_entry_rate_results'; +import { getLogEntryRatePartitionedSeries } from '../helpers/data_formatters'; +import { LogEntryRateBarChart } from './bar_chart'; export const LogRateResults = ({ isLoading, @@ -33,7 +34,9 @@ export const LogRateResults = ({ return ( <> -

{title}

+

+ {title} +

}> {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? ( diff --git a/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts index c23bab7026aaa..e9a966b97e4dd 100644 --- a/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts +++ b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts @@ -248,7 +248,7 @@ interface CancelablePromise { promise: Promise; } -class CanceledPromiseError extends Error { +export class CanceledPromiseError extends Error { public isCanceled = true; constructor(message?: string) { @@ -257,6 +257,6 @@ class CanceledPromiseError extends Error { } } -class SilentCanceledPromiseError extends CanceledPromiseError {} +export class SilentCanceledPromiseError extends CanceledPromiseError {} const noOp = () => undefined; diff --git a/x-pack/legacy/plugins/infra/server/infra_server.ts b/x-pack/legacy/plugins/infra/server/infra_server.ts index f99589e1b52bd..4f290cb05f056 100644 --- a/x-pack/legacy/plugins/infra/server/infra_server.ts +++ b/x-pack/legacy/plugins/infra/server/infra_server.ts @@ -12,6 +12,8 @@ import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; import { + initGetLogEntryCategoriesRoute, + initGetLogEntryCategoryDatasetsRoute, initGetLogEntryRateRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; @@ -41,6 +43,8 @@ export const initInfraServer = (libs: InfraBackendLibs) => { libs.framework.registerGraphQLEndpoint('/graphql', schema); initIpToHostName(libs); + initGetLogEntryCategoriesRoute(libs); + initGetLogEntryCategoryDatasetsRoute(libs); initGetLogEntryRateRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); diff --git a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts index 305841aa52d36..d8a39a6b9c16f 100644 --- a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts +++ b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts @@ -12,7 +12,7 @@ import { InfraFieldsDomain } from '../domains/fields_domain'; import { InfraLogEntriesDomain } from '../domains/log_entries_domain'; import { InfraMetricsDomain } from '../domains/metrics_domain'; import { InfraBackendLibs, InfraDomainLibs } from '../infra_types'; -import { InfraLogAnalysis } from '../log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from '../log_analysis'; import { InfraSnapshot } from '../snapshot'; import { InfraSourceStatus } from '../source_status'; import { InfraSources } from '../sources'; @@ -29,7 +29,8 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ sources, }); const snapshot = new InfraSnapshot({ sources, framework }); - const logAnalysis = new InfraLogAnalysis({ framework }); + const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); + const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { @@ -45,7 +46,8 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ const libs: InfraBackendLibs = { configuration: config, // NP_TODO: Do we ever use this anywhere? framework, - logAnalysis, + logEntryCategoriesAnalysis, + logEntryRateAnalysis, snapshot, sources, sourceStatus, diff --git a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts index 46d32885600df..d52416b39596b 100644 --- a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts @@ -8,7 +8,7 @@ import { InfraSourceConfiguration } from '../../public/graphql/types'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; -import { InfraLogAnalysis } from './log_analysis/log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './log_analysis'; import { InfraSnapshot } from './snapshot'; import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; @@ -31,7 +31,8 @@ export interface InfraDomainLibs { export interface InfraBackendLibs extends InfraDomainLibs { configuration: InfraConfig; framework: KibanaFramework; - logAnalysis: InfraLogAnalysis; + logEntryCategoriesAnalysis: LogEntryCategoriesAnalysis; + logEntryRateAnalysis: LogEntryRateAnalysis; snapshot: InfraSnapshot; sources: InfraSources; sourceStatus: InfraSourceStatus; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts index dc5c87c61fdce..d1c8316ad061b 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export class NoLogRateResultsIndexError extends Error { +export class NoLogAnalysisResultsIndexError extends Error { constructor(message?: string) { super(message); Object.setPrototypeOf(this, new.target.prototype); diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts index 0b58c71c1db7b..44c2bafce4194 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts @@ -5,4 +5,5 @@ */ export * from './errors'; -export * from './log_analysis'; +export * from './log_entry_categories_analysis'; +export * from './log_entry_rate_analysis'; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts new file mode 100644 index 0000000000000..f2b6c468df69f --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -0,0 +1,363 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { getJobId, logEntryCategoriesJobTypes } from '../../../common/log_analysis'; +import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; +import { NoLogAnalysisResultsIndexError } from './errors'; +import { + createLogEntryCategoriesQuery, + logEntryCategoriesResponseRT, + LogEntryCategoryHit, +} from './queries/log_entry_categories'; +import { + createLogEntryCategoryHistogramsQuery, + logEntryCategoryHistogramsResponseRT, +} from './queries/log_entry_category_histograms'; +import { + CompositeDatasetKey, + createLogEntryDatasetsQuery, + LogEntryDatasetBucket, + logEntryDatasetsResponseRT, +} from './queries/log_entry_data_sets'; +import { + createTopLogEntryCategoriesQuery, + topLogEntryCategoriesResponseRT, +} from './queries/top_log_entry_categories'; + +const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; + +export class LogEntryCategoriesAnalysis { + constructor( + private readonly libs: { + framework: KibanaFramework; + } + ) {} + + public async getTopLogEntryCategories( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[], + histograms: HistogramParameters[] + ) { + const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { + topLogEntryCategories, + timing: { spans: fetchTopLogEntryCategoriesAggSpans }, + } = await this.fetchTopLogEntryCategories( + requestContext, + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ); + + const categoryIds = topLogEntryCategories.map(({ categoryId }) => categoryId); + + const { + logEntryCategoriesById, + timing: { spans: fetchTopLogEntryCategoryPatternsSpans }, + } = await this.fetchLogEntryCategories( + requestContext, + logEntryCategoriesCountJobId, + categoryIds + ); + + const { + categoryHistogramsById, + timing: { spans: fetchTopLogEntryCategoryHistogramsSpans }, + } = await this.fetchTopLogEntryCategoryHistograms( + requestContext, + logEntryCategoriesCountJobId, + categoryIds, + histograms + ); + + const topLogEntryCategoriesSpan = finalizeTopLogEntryCategoriesSpan(); + + return { + data: topLogEntryCategories.map(topCategory => ({ + ...topCategory, + regularExpression: logEntryCategoriesById[topCategory.categoryId]?._source.regex ?? '', + histograms: categoryHistogramsById[topCategory.categoryId] ?? [], + })), + timing: { + spans: [ + topLogEntryCategoriesSpan, + ...fetchTopLogEntryCategoriesAggSpans, + ...fetchTopLogEntryCategoryPatternsSpans, + ...fetchTopLogEntryCategoryHistogramsSpans, + ], + }, + }; + } + + public async getLogEntryCategoryDatasets( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number + ) { + const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; + let afterLatestBatchKey: CompositeDatasetKey | undefined; + let esSearchSpans: TracingSpan[] = []; + + while (true) { + const finalizeEsSearchSpan = startTracingSpan('fetch category dataset batch from ES'); + + const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryDatasetsQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey + ) + ) + ); + + if (logEntryDatasetsResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` + ); + } + + const { + after_key: afterKey, + buckets: latestBatchBuckets, + } = logEntryDatasetsResponse.aggregations.dataset_buckets; + + logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; + esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; + + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; + } + } + + const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); + + return { + data: logEntryDatasetBuckets.map(logEntryDatasetBucket => logEntryDatasetBucket.key.dataset), + timing: { + spans: [logEntryDatasetsSpan, ...esSearchSpans], + }, + }; + } + + private async fetchTopLogEntryCategories( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[] + ) { + const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); + + const topLogEntryCategoriesResponse = decodeOrThrow(topLogEntryCategoriesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createTopLogEntryCategoriesQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + if (topLogEntryCategoriesResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` + ); + } + + const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( + topCategoryBucket => ({ + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets.map( + datasetBucket => datasetBucket.key + ), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, + }) + ); + + return { + topLogEntryCategories, + timing: { + spans: [esSearchSpan], + }, + }; + } + + private async fetchLogEntryCategories( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + categoryIds: number[] + ) { + if (categoryIds.length === 0) { + return { + logEntryCategoriesById: {}, + timing: { spans: [] }, + }; + } + + const finalizeEsSearchSpan = startTracingSpan('Fetch category patterns from ES'); + + const logEntryCategoriesResponse = decodeOrThrow(logEntryCategoriesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryCategoriesQuery(logEntryCategoriesCountJobId, categoryIds) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const logEntryCategoriesById = logEntryCategoriesResponse.hits.hits.reduce< + Record + >( + (accumulatedCategoriesById, categoryHit) => ({ + ...accumulatedCategoriesById, + [categoryHit._source.category_id]: categoryHit, + }), + {} + ); + + return { + logEntryCategoriesById, + timing: { + spans: [esSearchSpan], + }, + }; + } + + private async fetchTopLogEntryCategoryHistograms( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + categoryIds: number[], + histograms: HistogramParameters[] + ) { + if (categoryIds.length === 0 || histograms.length === 0) { + return { + categoryHistogramsById: {}, + timing: { spans: [] }, + }; + } + + const finalizeEsSearchSpan = startTracingSpan('Fetch category histograms from ES'); + + const categoryHistogramsReponses = await Promise.all( + histograms.map(({ bucketCount, endTime, id: histogramId, startTime }) => + this.libs.framework + .callWithRequest( + requestContext, + 'search', + createLogEntryCategoryHistogramsQuery( + logEntryCategoriesCountJobId, + categoryIds, + startTime, + endTime, + bucketCount + ) + ) + .then(decodeOrThrow(logEntryCategoryHistogramsResponseRT)) + .then(response => ({ + histogramId, + histogramBuckets: response.aggregations.filters_categories.buckets, + })) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const categoryHistogramsById = Object.values(categoryHistogramsReponses).reduce< + Record< + number, + Array<{ + histogramId: string; + buckets: Array<{ + bucketDuration: number; + logEntryCount: number; + startTime: number; + }>; + }> + > + >( + (outerAccumulatedHistograms, { histogramId, histogramBuckets }) => + Object.entries(histogramBuckets).reduce( + (innerAccumulatedHistograms, [categoryBucketKey, categoryBucket]) => { + const categoryId = parseCategoryId(categoryBucketKey); + return { + ...innerAccumulatedHistograms, + [categoryId]: [ + ...(innerAccumulatedHistograms[categoryId] ?? []), + { + histogramId, + buckets: categoryBucket.histogram_timestamp.buckets.map(bucket => ({ + bucketDuration: categoryBucket.histogram_timestamp.meta.bucketDuration, + logEntryCount: bucket.sum_actual.value, + startTime: bucket.key, + })), + }, + ], + }; + }, + outerAccumulatedHistograms + ), + {} + ); + + return { + categoryHistogramsById, + timing: { + spans: [esSearchSpan], + }, + }; + } +} + +const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10); + +interface HistogramParameters { + id: string; + startTime: number; + endTime: number; + bucketCount: number; +} diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts similarity index 95% rename from x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts rename to x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index fac49a7980f26..515856fa6be8a 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -10,7 +10,7 @@ import { identity } from 'fp-ts/lib/function'; import { getJobId } from '../../../common/log_analysis'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; -import { NoLogRateResultsIndexError } from './errors'; +import { NoLogAnalysisResultsIndexError } from './errors'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, @@ -21,7 +21,7 @@ import { RequestHandlerContext, KibanaRequest } from '../../../../../../../src/c const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; -export class InfraLogAnalysis { +export class LogEntryRateAnalysis { constructor( private readonly libs: { framework: KibanaFramework; @@ -36,11 +36,11 @@ export class InfraLogAnalysis { public async getLogEntryRateBuckets( requestContext: RequestHandlerContext, + request: KibanaRequest, sourceId: string, startTime: number, endTime: number, - bucketDuration: number, - request: KibanaRequest + bucketDuration: number ) { const logRateJobId = this.getJobIds(request, sourceId).logEntryRate; let mlModelPlotBuckets: LogRateModelPlotBucket[] = []; @@ -61,7 +61,7 @@ export class InfraLogAnalysis { ); if (mlModelPlotResponse._shards.total === 0) { - throw new NoLogRateResultsIndexError( + throw new NoLogAnalysisResultsIndexError( `Failed to find ml result index for job ${logRateJobId}.` ); } diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts new file mode 100644 index 0000000000000..92ef4fb4e35c9 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; + +export const getMlResultIndex = (jobId: string) => `${ML_ANOMALY_INDEX_PREFIX}${jobId}`; + +export const defaultRequestParameters = { + allowNoIndices: true, + ignoreUnavailable: true, + trackScores: false, + trackTotalHits: false, +}; + +export const createTimeRangeFilters = (startTime: number, endTime: number) => [ + { + range: { + timestamp: { + gte: startTime, + lte: endTime, + }, + }, + }, +]; + +export const createResultTypeFilters = (resultType: 'model_plot' | 'record') => [ + { + term: { + result_type: { + value: resultType, + }, + }, + }, +]; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts index 1749421277719..8c470acbf02fb 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts @@ -5,3 +5,4 @@ */ export * from './log_entry_rate'; +export * from './top_log_entry_categories'; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts new file mode 100644 index 0000000000000..63b3632f03784 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters, getMlResultIndex } from './common'; + +export const createLogEntryCategoriesQuery = ( + logEntryCategoriesJobId: string, + categoryIds: number[] +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + terms: { + category_id: categoryIds, + }, + }, + ], + }, + }, + _source: ['category_id', 'regex'], + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: categoryIds.length, +}); + +export const logEntryCategoryHitRT = rt.type({ + _source: rt.type({ + category_id: rt.number, + regex: rt.string, + }), +}); + +export type LogEntryCategoryHit = rt.TypeOf; + +export const logEntryCategoriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryCategoryHitRT), + }), + }), +]); + +export type logEntryCategoriesResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts new file mode 100644 index 0000000000000..67087f3b4775b --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, + getMlResultIndex, +} from './common'; + +export const createLogEntryCategoryHistogramsQuery = ( + logEntryCategoriesJobId: string, + categoryIds: number[], + startTime: number, + endTime: number, + bucketCount: number +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters('model_plot'), + ...createCategoryFilters(categoryIds), + ], + }, + }, + aggs: { + filters_categories: { + filters: createCategoryFiltersAggregation(categoryIds), + aggs: { + histogram_timestamp: createHistogramAggregation(startTime, endTime, bucketCount), + }, + }, + }, + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: 0, +}); + +const createCategoryFilters = (categoryIds: number[]) => [ + { + terms: { + by_field_value: categoryIds, + }, + }, +]; + +const createCategoryFiltersAggregation = (categoryIds: number[]) => ({ + filters: categoryIds.reduce>( + (categoryFilters, categoryId) => ({ + ...categoryFilters, + [`${categoryId}`]: { + term: { + by_field_value: categoryId, + }, + }, + }), + {} + ), +}); + +const createHistogramAggregation = (startTime: number, endTime: number, bucketCount: number) => { + const bucketDuration = Math.round((endTime - startTime) / bucketCount); + + return { + histogram: { + field: 'timestamp', + interval: bucketDuration, + offset: startTime, + }, + meta: { + bucketDuration, + }, + aggs: { + sum_actual: { + sum: { + field: 'actual', + }, + }, + }, + }; +}; + +export const logEntryCategoryFilterBucketRT = rt.type({ + doc_count: rt.number, + histogram_timestamp: rt.type({ + meta: rt.type({ + bucketDuration: rt.number, + }), + buckets: rt.array( + rt.type({ + key: rt.number, + doc_count: rt.number, + sum_actual: rt.type({ + value: rt.number, + }), + }) + ), + }), +}); + +export type LogEntryCategoryFilterBucket = rt.TypeOf; + +export const logEntryCategoryHistogramsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + filters_categories: rt.type({ + buckets: rt.record(rt.string, logEntryCategoryFilterBucketRT), + }), + }), + }), +]); + +export type LogEntryCategorHistogramsResponse = rt.TypeOf< + typeof logEntryCategoryHistogramsResponseRT +>; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts new file mode 100644 index 0000000000000..b41a21a21b6a6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters, getMlResultIndex } from './common'; + +export const createLogEntryDatasetsQuery = ( + logEntryAnalysisJobId: string, + startTime: number, + endTime: number, + size: number, + afterKey?: CompositeDatasetKey +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + timestamp: { + gte: startTime, + lt: endTime, + }, + }, + }, + { + term: { + result_type: { + value: 'model_plot', + }, + }, + }, + ], + }, + }, + aggs: { + dataset_buckets: { + composite: { + after: afterKey, + size, + sources: [ + { + dataset: { + terms: { + field: 'partition_field_value', + order: 'asc', + }, + }, + }, + ], + }, + }, + }, + }, + index: getMlResultIndex(logEntryAnalysisJobId), + size: 0, +}); + +const compositeDatasetKeyRT = rt.type({ + dataset: rt.string, +}); + +export type CompositeDatasetKey = rt.TypeOf; + +const logEntryDatasetBucketRT = rt.type({ + key: compositeDatasetKeyRT, +}); + +export type LogEntryDatasetBucket = rt.TypeOf; + +export const logEntryDatasetsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + dataset_buckets: rt.intersection([ + rt.type({ + buckets: rt.array(logEntryDatasetBucketRT), + }), + rt.partial({ + after_key: compositeDatasetKeyRT, + }), + ]), + }), + }), +]); + +export type LogEntryDatasetsResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 2dd0880cbf8cb..def7caf578b94 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -6,7 +6,7 @@ import * as rt from 'io-ts'; -const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; +import { defaultRequestParameters, getMlResultIndex } from './common'; export const createLogEntryRateQuery = ( logRateJobId: string, @@ -16,7 +16,7 @@ export const createLogEntryRateQuery = ( size: number, afterKey?: CompositeTimestampPartitionKey ) => ({ - allowNoIndices: true, + ...defaultRequestParameters, body: { query: { bool: { @@ -118,11 +118,8 @@ export const createLogEntryRateQuery = ( }, }, }, - ignoreUnavailable: true, - index: `${ML_ANOMALY_INDEX_PREFIX}${logRateJobId}`, + index: getMlResultIndex(logRateJobId), size: 0, - trackScores: false, - trackTotalHits: false, }); const logRateMlRecordRT = rt.type({ diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts new file mode 100644 index 0000000000000..22b0ef748f5f8 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, + getMlResultIndex, +} from './common'; + +export const createTopLogEntryCategoriesQuery = ( + logEntryCategoriesJobId: string, + startTime: number, + endTime: number, + size: number, + datasets: string[], + sortDirection: 'asc' | 'desc' = 'desc' +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + ...createTimeRangeFilters(startTime, endTime), + ...createDatasetsFilters(datasets), + { + bool: { + should: [ + { + bool: { + filter: [ + ...createResultTypeFilters('model_plot'), + { + range: { + actual: { + gt: 0, + }, + }, + }, + ], + }, + }, + { + bool: { + filter: createResultTypeFilters('record'), + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + aggs: { + terms_category_id: { + terms: { + field: 'by_field_value', + size, + order: { + 'filter_model_plot>sum_actual': sortDirection, + }, + }, + aggs: { + filter_model_plot: { + filter: { + term: { + result_type: 'model_plot', + }, + }, + aggs: { + sum_actual: { + sum: { + field: 'actual', + }, + }, + terms_dataset: { + terms: { + field: 'partition_field_value', + size: 1000, + }, + }, + }, + }, + filter_record: { + filter: { + term: { + result_type: 'record', + }, + }, + aggs: { + maximum_record_score: { + max: { + field: 'record_score', + }, + }, + }, + }, + }, + }, + }, + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: 0, +}); + +const createDatasetsFilters = (datasets: string[]) => + datasets.length > 0 + ? [ + { + terms: { + partition_field_value: datasets, + }, + }, + ] + : []; + +const metricAggregationRT = rt.type({ + value: rt.union([rt.number, rt.null]), +}); + +export const logEntryCategoryBucketRT = rt.type({ + key: rt.string, + doc_count: rt.number, + filter_record: rt.type({ + maximum_record_score: metricAggregationRT, + }), + filter_model_plot: rt.type({ + sum_actual: metricAggregationRT, + terms_dataset: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.string, + doc_count: rt.number, + }) + ), + }), + }), +}); + +export type LogEntryCategoryBucket = rt.TypeOf; + +export const topLogEntryCategoriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + terms_category_id: rt.type({ + buckets: rt.array(logEntryCategoryBucketRT), + }), + }), + }), +]); + +export type TopLogEntryCategoriesResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts b/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts index 147729a1d0b3e..d3c6f7a5f70a1 100644 --- a/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts +++ b/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts @@ -17,7 +17,7 @@ import { InfraElasticsearchSourceStatusAdapter } from './lib/adapters/source_sta import { InfraFieldsDomain } from './lib/domains/fields_domain'; import { InfraLogEntriesDomain } from './lib/domains/log_entries_domain'; import { InfraMetricsDomain } from './lib/domains/metrics_domain'; -import { InfraLogAnalysis } from './lib/log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './lib/log_analysis'; import { InfraSnapshot } from './lib/snapshot'; import { InfraSourceStatus } from './lib/source_status'; import { InfraSources } from './lib/sources'; @@ -87,7 +87,8 @@ export class InfraServerPlugin { } ); const snapshot = new InfraSnapshot({ sources, framework }); - const logAnalysis = new InfraLogAnalysis({ framework }); + const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); + const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { @@ -103,7 +104,8 @@ export class InfraServerPlugin { this.libs = { configuration: this.config, framework, - logAnalysis, + logEntryCategoriesAnalysis, + logEntryRateAnalysis, snapshot, sources, sourceStatus, diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts index 1749421277719..d9ca9a96ffe51 100644 --- a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './log_entry_categories'; +export * from './log_entry_category_datasets'; export * from './log_entry_rate'; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts new file mode 100644 index 0000000000000..7eb7de57b2f92 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { schema } from '@kbn/config-schema'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, + getLogEntryCategoriesRequestPayloadRT, + getLogEntryCategoriesSuccessReponsePayloadRT, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoriesRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + categoryCount, + histograms, + sourceId, + timeRange: { startTime, endTime }, + datasets, + }, + } = pipe( + getLogEntryCategoriesRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: topLogEntryCategories, + timing, + } = await logEntryCategoriesAnalysis.getTopLogEntryCategories( + requestContext, + request, + sourceId, + startTime, + endTime, + categoryCount, + datasets ?? [], + histograms.map(histogram => ({ + bucketCount: histogram.bucketCount, + endTime: histogram.timeRange.endTime, + id: histogram.id, + startTime: histogram.timeRange.startTime, + })) + ); + + return response.ok({ + body: getLogEntryCategoriesSuccessReponsePayloadRT.encode({ + data: { + categories: topLogEntryCategories, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts new file mode 100644 index 0000000000000..8132633028277 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + getLogEntryCategoryDatasetsRequestPayloadRT, + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoryDatasetsRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + sourceId, + timeRange: { startTime, endTime }, + }, + } = pipe( + getLogEntryCategoryDatasetsRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: logEntryCategoryDatasets, + timing, + } = await logEntryCategoriesAnalysis.getLogEntryCategoryDatasets( + requestContext, + request, + sourceId, + startTime, + endTime + ); + + return response.ok({ + body: getLogEntryCategoryDatasetsSuccessReponsePayloadRT.encode({ + data: { + datasets: logEntryCategoryDatasets, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index 9778311bd8e58..6551316fd0c64 100644 --- a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -18,11 +18,11 @@ import { GetLogEntryRateSuccessResponsePayload, } from '../../../../common/http_api/log_analysis'; import { throwErrors } from '../../../../common/runtime_types'; -import { NoLogRateResultsIndexError } from '../../../lib/log_analysis'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; const anyObject = schema.object({}, { allowUnknowns: true }); -export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBackendLibs) => { +export const initGetLogEntryRateRoute = ({ framework, logEntryRateAnalysis }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', @@ -39,13 +39,13 @@ export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBacken fold(throwErrors(Boom.badRequest), identity) ); - const logEntryRateBuckets = await logAnalysis.getLogEntryRateBuckets( + const logEntryRateBuckets = await logEntryRateAnalysis.getLogEntryRateBuckets( requestContext, + request, payload.data.sourceId, payload.data.timeRange.startTime, payload.data.timeRange.endTime, - payload.data.bucketDuration, - request + payload.data.bucketDuration ); return response.ok({ @@ -59,7 +59,7 @@ export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBacken }); } catch (e) { const { statusCode = 500, message = 'Unknown error occurred' } = e; - if (e instanceof NoLogRateResultsIndexError) { + if (e instanceof NoLogAnalysisResultsIndexError) { return response.notFound({ body: { message } }); } return response.customError({ diff --git a/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts b/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts new file mode 100644 index 0000000000000..a48c65d648b25 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const commonSearchSuccessResponseFieldsRT = rt.type({ + _shards: rt.type({ + total: rt.number, + successful: rt.number, + skipped: rt.number, + failed: rt.number, + }), + timed_out: rt.boolean, + took: rt.number, +}); diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index c4a684381b17c..a4eb24d4a4de4 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -11,6 +11,7 @@ import KbnServer, { Server } from 'src/legacy/server/kbn_server'; import mappings from './mappings.json'; import { PLUGIN_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from './common'; import { lensServerPlugin } from './server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../task_manager/server'; export const lens: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ @@ -64,6 +65,12 @@ export const lens: LegacyPluginInitializer = kibana => { savedObjects: server.savedObjects, config: server.config(), server, + taskManager: getTaskManagerSetup(server)!, + }); + + plugin.start(kbnServer.newPlatform.start.core, { + server, + taskManager: getTaskManagerStart(server)!, }); server.events.on('stop', () => { diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index 1cdae05833b98..794128832461b 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -80,6 +80,7 @@ describe('Lens App', () => { docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }> { return ({ editorFrame: createMockFrame(), @@ -126,6 +127,7 @@ describe('Lens App', () => { docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }>; } @@ -306,6 +308,7 @@ describe('Lens App', () => { docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }>; beforeEach(() => { @@ -344,14 +347,19 @@ describe('Lens App', () => { async function save({ initialDocId, + addToDashboardMode, ...saveProps }: SaveProps & { initialDocId?: string; + addToDashboardMode?: boolean; }) { const args = { ...defaultArgs, docId: initialDocId, }; + if (addToDashboardMode) { + args.addToDashboardMode = addToDashboardMode; + } args.editorFrame = frame; (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', @@ -543,6 +551,23 @@ describe('Lens App', () => { expect(getButton(instance).disableButton).toEqual(false); }); + + it('saves new doc and redirects to dashboard', async () => { + const { args } = await save({ + initialDocId: undefined, + addToDashboardMode: true, + newCopyOnSave: false, + newTitle: 'hello there', + }); + + expect(args.docStorage.save).toHaveBeenCalledWith({ + expression: 'kibana 3', + id: undefined, + title: 'hello there', + }); + + expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + }); }); }); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index cb57f2c884e38..f33cd41f46a11 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -13,6 +13,7 @@ import { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_s import { AppMountContext, NotificationsStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { npStart } from 'ui/new_platform'; +import { FormattedMessage } from '@kbn/i18n/react'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; @@ -50,6 +51,7 @@ export function App({ docId, docStorage, redirectTo, + addToDashboardMode, }: { editorFrame: EditorFrameInstance; data: DataPublicPluginStart; @@ -58,6 +60,7 @@ export function App({ docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }) { const language = storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); @@ -166,6 +169,13 @@ export function App({ const { TopNavMenu } = npStart.plugins.navigation.ui; + const confirmButton = addToDashboardMode ? ( + + ) : null; + return ( { + .catch(e => { + // eslint-disable-next-line no-console + console.dir(e); trackUiEvent('save_failed'); core.notifications.toasts.addDanger( i18n.translate('xpack.lens.app.docSavingError', { @@ -337,10 +348,11 @@ export function App({ }} onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))} title={lastKnownDoc.title || ''} - showCopyOnSave={true} + showCopyOnSave={!addToDashboardMode} objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', })} + confirmButtonLabel={confirmButton} /> )} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index b1eac8e287bd8..7465de2dba7f1 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -14,11 +14,13 @@ import 'uiExports/visResponseHandlers'; import 'uiExports/savedObjectTypes'; import React from 'react'; -import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; -import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; import { render, unmountComponentAtNode } from 'react-dom'; import { CoreSetup, CoreStart, SavedObjectsClientContract } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import rison, { RisonObject, RisonValue } from 'rison-node'; +import { isObject } from 'lodash'; import { DataStart } from '../../../../../../src/legacy/core_plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import { editorFrameSetup, editorFrameStart, editorFrameStop } from '../editor_frame_plugin'; @@ -41,6 +43,11 @@ import { import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../common'; import { KibanaLegacySetup } from '../../../../../../src/plugins/kibana_legacy/public'; import { EditorFrameStart } from '../types'; +import { + addEmbeddableToDashboardUrl, + getUrlVars, + getLensUrlFromDashboardAbsoluteUrl, +} from '../../../../../../src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper'; export interface LensPluginSetupDependencies { kibana_legacy: KibanaLegacySetup; @@ -51,6 +58,9 @@ export interface LensPluginStartDependencies { dataShim: DataStart; } +export const isRisonObject = (value: RisonValue): value is RisonObject => { + return isObject(value); +}; export class AppPlugin { private startDependencies: { data: DataPublicPluginStart; @@ -84,7 +94,6 @@ export class AppPlugin { } const { data, savedObjectsClient, editorFrame } = this.startDependencies; addHelpMenuToAppChrome(context.core.chrome); - const instance = editorFrame.createInstance({}); setReportManager( @@ -93,9 +102,60 @@ export class AppPlugin { http: core.http, }) ); + const updateUrlTime = (urlVars: Record): void => { + const decoded: RisonObject = rison.decode(urlVars._g) as RisonObject; + if (!decoded) { + return; + } + // @ts-ignore + decoded.time = data.query.timefilter.timefilter.getTime(); + urlVars._g = rison.encode((decoded as unknown) as RisonObject); + }; + const redirectTo = ( + routeProps: RouteComponentProps<{ id?: string }>, + addToDashboardMode: boolean, + id?: string + ) => { + if (!id) { + routeProps.history.push('/lens'); + } else if (!addToDashboardMode) { + routeProps.history.push(`/lens/edit/${id}`); + } else if (addToDashboardMode && id) { + routeProps.history.push(`/lens/edit/${id}`); + const url = context.core.chrome.navLinks.get('kibana:dashboard'); + if (!url) { + throw new Error('Cannot get last dashboard url'); + } + const lastDashboardAbsoluteUrl = url.url; + const basePath = context.core.http.basePath.get(); + const lensUrl = getLensUrlFromDashboardAbsoluteUrl( + lastDashboardAbsoluteUrl, + basePath, + id + ); + if (!lastDashboardAbsoluteUrl || !lensUrl) { + throw new Error('Cannot get last dashboard url'); + } + window.history.pushState({}, '', lensUrl); + const urlVars = getUrlVars(lastDashboardAbsoluteUrl); + updateUrlTime(urlVars); // we need to pass in timerange in query params directly + const dashboardParsedUrl = addEmbeddableToDashboardUrl( + lastDashboardAbsoluteUrl, + basePath, + id, + urlVars + ); + if (!dashboardParsedUrl) { + throw new Error('Problem parsing dashboard url'); + } + window.history.pushState({}, '', dashboardParsedUrl); + } + }; const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { trackUiEvent('loaded'); + const addToDashboardMode = + !!routeProps.location.search && routeProps.location.search.includes('addToDashboard'); return ( { - if (!id) { - routeProps.history.push('/lens'); - } else { - routeProps.history.push(`/lens/edit/${id}`); - } - }} + redirectTo={id => redirectTo(routeProps, addToDashboardMode, id)} + addToDashboardMode={addToDashboardMode} /> ); }; @@ -119,6 +174,7 @@ export class AppPlugin { trackUiEvent('loaded_404'); return ; } + render( diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx index 25d88fbae5b34..cb9350226575c 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx @@ -72,6 +72,42 @@ describe('Datatable Visualization', () => { }); }); + describe('#getLayerIds', () => { + it('return the layer ids', () => { + const state: DatatableVisualizationState = { + layers: [ + { + layerId: 'baz', + columns: ['a', 'b', 'c'], + }, + ], + }; + expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']); + }); + }); + + describe('#clearLayer', () => { + it('should reset the layer', () => { + (generateId as jest.Mock).mockReturnValueOnce('testid'); + const state: DatatableVisualizationState = { + layers: [ + { + layerId: 'baz', + columns: ['a', 'b', 'c'], + }, + ], + }; + expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({ + layers: [ + { + layerId: 'baz', + columns: ['testid'], + }, + ], + }); + }); + }); + describe('#getSuggestions', () => { function numCol(columnId: string): TableSuggestionColumn { return { @@ -188,6 +224,7 @@ describe('Datatable Visualization', () => { mount( {} }} frame={frame} layer={layer} @@ -224,6 +261,7 @@ describe('Datatable Visualization', () => { frame.datasourceLayers = { a: datasource.publicAPIMock }; const component = mount( {} }} frame={frame} layer={layer} @@ -258,6 +296,7 @@ describe('Datatable Visualization', () => { frame.datasourceLayers = { a: datasource.publicAPIMock }; const component = mount( {} }} frame={frame} layer={layer} @@ -290,6 +329,7 @@ describe('Datatable Visualization', () => { frame.datasourceLayers = { a: datasource.publicAPIMock }; const component = mount( {} }} frame={frame} layer={layer} diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx index f9a7ec419a9b9..79a018635134f 100644 --- a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -6,19 +6,18 @@ import React from 'react'; import { render } from 'react-dom'; -import { EuiForm, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { MultiColumnEditor } from '../multi_column_editor'; import { SuggestionRequest, Visualization, - VisualizationProps, + VisualizationLayerConfigProps, VisualizationSuggestion, Operation, } from '../types'; import { generateId } from '../id_generator'; -import { NativeRenderer } from '../native_renderer'; import chartTableSVG from '../assets/chart_datatable.svg'; export interface LayerState { @@ -56,7 +55,7 @@ export function DataTableLayer({ state, setState, dragDropContext, -}: { layer: LayerState } & VisualizationProps) { +}: { layer: LayerState } & VisualizationLayerConfigProps) { const datasource = frame.datasourceLayers[layer.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); @@ -64,32 +63,24 @@ export function DataTableLayer({ const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns))); return ( - - + setState(updateColumns(state, layer, columns => [...columns, generateId()]))} + onRemove={column => + setState(updateColumns(state, layer, columns => columns.filter(c => c !== column))) + } + testSubj="datatable_columns" + data-test-subj="datatable_multicolumnEditor" /> - - - - setState(updateColumns(state, layer, columns => [...columns, generateId()]))} - onRemove={column => - setState(updateColumns(state, layer, columns => columns.filter(c => c !== column))) - } - testSubj="datatable_columns" - data-test-subj="datatable_multicolumnEditor" - /> - - + ); } @@ -110,7 +101,17 @@ export const datatableVisualization: Visualization< }, ], - getDescription(state) { + getLayerIds(state) { + return state.layers.map(l => l.layerId); + }, + + clearLayer(state) { + return { + layers: state.layers.map(l => newLayerState(l.layerId)), + }; + }, + + getDescription() { return { icon: chartTableSVG, label: i18n.translate('xpack.lens.datatable.label', { @@ -187,17 +188,18 @@ export const datatableVisualization: Visualization< ]; }, - renderConfigPanel: (domElement, props) => - render( - - - {props.state.layers.map(layer => ( - - ))} - - , - domElement - ), + renderLayerConfigPanel(domElement, props) { + const layer = props.state.layers.find(l => l.layerId === props.layerId); + + if (layer) { + render( + + + , + domElement + ); + } + }, toExpression(state, frame) { const layer = state.layers[0]; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx index dca6b3e7616d6..5e2fced577724 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx @@ -81,7 +81,6 @@ export function ChartSwitch(props: Props) { trackUiEvent(`chart_switch`); switchToSuggestion( - props.framePublicAPI, props.dispatch, { ...selection, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx index 4179a9455eefa..1422ee86be3e9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -4,14 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useContext, memo } from 'react'; +import React, { useMemo, useContext, memo, useState } from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiPopover, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiToolTip, + EuiButton, + EuiForm, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { Visualization, FramePublicAPI, Datasource } from '../../types'; +import { + Visualization, + FramePublicAPI, + Datasource, + VisualizationLayerConfigProps, +} from '../../types'; import { DragContext } from '../../drag_drop'; import { ChartSwitch } from './chart_switch'; +import { trackUiEvent } from '../../lens_ui_telemetry'; +import { generateId } from '../../id_generator'; +import { removeLayer, appendLayer } from './layer_actions'; interface ConfigPanelWrapperProps { + activeDatasourceId: string; visualizationState: unknown; visualizationMap: Record; activeVisualizationId: string | null; @@ -28,17 +50,8 @@ interface ConfigPanelWrapperProps { } export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { - const context = useContext(DragContext); - const setVisualizationState = useMemo( - () => (newState: unknown) => { - props.dispatch({ - type: 'UPDATE_VISUALIZATION_STATE', - newState, - clearStagedPreview: false, - }); - }, - [props.dispatch] - ); + const activeVisualization = props.visualizationMap[props.activeVisualizationId || '']; + const { visualizationState } = props; return ( <> @@ -52,19 +65,235 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config dispatch={props.dispatch} framePublicAPI={props.framePublicAPI} /> - {props.activeVisualizationId && props.visualizationState !== null && ( -
- -
+ {activeVisualization && visualizationState && ( + )} ); }); + +function LayerPanels( + props: ConfigPanelWrapperProps & { + activeDatasourceId: string; + activeVisualization: Visualization; + } +) { + const { + framePublicAPI, + activeVisualization, + visualizationState, + dispatch, + activeDatasourceId, + datasourceMap, + } = props; + const dragDropContext = useContext(DragContext); + const setState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, + newState, + clearStagedPreview: false, + }); + }, + [props.dispatch, activeVisualization] + ); + const layerIds = activeVisualization.getLayerIds(visualizationState); + + return ( + + {layerIds.map(layerId => ( + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'REMOVE_OR_CLEAR_LAYER', + updater: state => + removeLayer({ + activeVisualization, + layerId, + trackUiEvent, + datasourceMap, + state, + }), + }); + }} + /> + ))} + {activeVisualization.appendLayer && ( + + + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'ADD_LAYER', + updater: state => + appendLayer({ + activeVisualization, + generateId, + trackUiEvent, + activeDatasource: datasourceMap[activeDatasourceId], + state, + }), + }); + }} + iconType="plusInCircleFilled" + /> + + + )} + + ); +} + +function LayerPanel( + props: ConfigPanelWrapperProps & + VisualizationLayerConfigProps & { + isOnlyLayer: boolean; + activeVisualization: Visualization; + onRemove: () => void; + } +) { + const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemove } = props; + const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; + const layerConfigProps = { + layerId, + dragDropContext: props.dragDropContext, + state: props.visualizationState, + setState: props.setState, + frame: props.framePublicAPI, + }; + + return ( + + + + + + + {datasourcePublicAPI && ( + + + + )} + + + + + + + + + + + { + // If we don't blur the remove / clear button, it remains focused + // which is a strange UX in this case. e.target.blur doesn't work + // due to who knows what, but probably event re-writing. Additionally, + // activeElement does not have blur so, we need to do some casting + safeguards. + const el = (document.activeElement as unknown) as { blur: () => void }; + + if (el && el.blur) { + el.blur(); + } + + onRemove(); + }} + > + {isOnlyLayer + ? i18n.translate('xpack.lens.resetLayer', { + defaultMessage: 'Reset layer', + }) + : i18n.translate('xpack.lens.deleteLayer', { + defaultMessage: 'Delete layer', + })} + + + + + ); +} + +function LayerSettings({ + layerId, + activeVisualization, + layerConfigProps, +}: { + layerId: string; + activeVisualization: Visualization; + layerConfigProps: VisualizationLayerConfigProps; +}) { + const [isOpen, setIsOpen] = useState(false); + + if (!activeVisualization.renderLayerContextMenu) { + return null; + } + + return ( + setIsOpen(!isOpen)} + data-test-subj="lns_layer_settings" + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="leftUp" + > + + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx index cf711eea29b96..c9b9a43376651 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -9,7 +9,7 @@ import { ReactWrapper } from 'enzyme'; import { EuiPanel, EuiToolTip } from '@elastic/eui'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EditorFrame } from './editor_frame'; -import { Visualization, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; +import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types'; import { act } from 'react-dom/test-utils'; import { coreMock } from 'src/core/public/mocks'; import { @@ -24,7 +24,11 @@ import { FrameLayout } from './frame_layout'; // calling this function will wait for all pending Promises from mock // datasources to be processed by its callers. -const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); +async function waitForPromises(n = 3) { + for (let i = 0; i < n; ++i) { + await Promise.resolve(); + } +} function generateSuggestion(state = {}): DatasourceSuggestion { return { @@ -88,6 +92,9 @@ describe('editor_frame', () => { ], }; + mockVisualization.getLayerIds.mockReturnValue(['first']); + mockVisualization2.getLayerIds.mockReturnValue(['second']); + mockDatasource = createMockDatasource(); mockDatasource2 = createMockDatasource(); @@ -202,7 +209,7 @@ describe('editor_frame', () => { ); }); - expect(mockVisualization.renderConfigPanel).not.toHaveBeenCalled(); + expect(mockVisualization.renderLayerConfigPanel).not.toHaveBeenCalled(); expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); }); @@ -294,6 +301,7 @@ describe('editor_frame', () => { it('should remove layer on active datasource on frame api call', async () => { const initialState = { datasource2: '' }; + mockDatasource.getLayers.mockReturnValue(['first']); mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); mockDatasource2.getLayers.mockReturnValue(['abc', 'def']); mockDatasource2.removeLayer.mockReturnValue({ removed: true }); @@ -361,7 +369,7 @@ describe('editor_frame', () => { it('should initialize visualization state and render config panel', async () => { const initialState = {}; - + mockDatasource.getLayers.mockReturnValue(['first']); mount( { await waitForPromises(); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: initialState }) ); @@ -390,6 +398,7 @@ describe('editor_frame', () => { it('should render the resulting expression using the expression renderer', async () => { mockDatasource.getLayers.mockReturnValue(['first']); + const instance = mount( { /> ); - await waitForPromises(); await waitForPromises(); instance.update(); @@ -601,6 +609,7 @@ describe('editor_frame', () => { describe('state update', () => { it('should re-render config panel after state update', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); mount( { await waitForPromises(); const updatedState = {}; - const setVisualizationState = (mockVisualization.renderConfigPanel as jest.Mock).mock + const setVisualizationState = (mockVisualization.renderLayerConfigPanel as jest.Mock).mock .calls[0][1].setState; act(() => { setVisualizationState(updatedState); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); - expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ state: updatedState, @@ -635,6 +644,7 @@ describe('editor_frame', () => { }); it('should re-render data panel after state update', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); mount( { await waitForPromises(); - const updatedPublicAPI = {}; - mockDatasource.getPublicAPI.mockReturnValue( - (updatedPublicAPI as unknown) as DatasourcePublicAPI - ); + const updatedPublicAPI: DatasourcePublicAPI = { + renderLayerPanel: jest.fn(), + renderDimensionPanel: jest.fn(), + getOperationForColumnId: jest.fn(), + getTableSpec: jest.fn(), + }; + mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI); const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] .setState; @@ -700,8 +713,8 @@ describe('editor_frame', () => { setDatasourceState({}); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); - expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenLastCalledWith( expect.any(Element), expect.objectContaining({ frame: expect.objectContaining({ @@ -754,10 +767,10 @@ describe('editor_frame', () => { await waitForPromises(); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalled(); const datasourceLayers = - mockVisualization.renderConfigPanel.mock.calls[0][1].frame.datasourceLayers; + mockVisualization.renderLayerConfigPanel.mock.calls[0][1].frame.datasourceLayers; expect(datasourceLayers.first).toBe(mockDatasource.publicAPIMock); expect(datasourceLayers.second).toBe(mockDatasource2.publicAPIMock); expect(datasourceLayers.third).toBe(mockDatasource2.publicAPIMock); @@ -919,7 +932,7 @@ describe('editor_frame', () => { } beforeEach(async () => { - mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getLayers.mockReturnValue(['first', 'second']); mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ { state: {}, @@ -1018,7 +1031,7 @@ describe('editor_frame', () => { expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith(expect.anything(), initialState); - expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: { initial: true } }) ); @@ -1032,9 +1045,11 @@ describe('editor_frame', () => { expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); expect(mockVisualization2.initialize).toHaveBeenCalledWith( - expect.objectContaining({ datasourceLayers: { first: mockDatasource.publicAPIMock } }) + expect.objectContaining({ + datasourceLayers: expect.objectContaining({ first: mockDatasource.publicAPIMock }), + }) ); - expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: { initial: true } }) ); @@ -1102,6 +1117,7 @@ describe('editor_frame', () => { }); it('should display top 5 suggestions in descending order', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); const instance = mount( { }); it('should switch to suggested visualization', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const newDatasourceState = {}; const suggestionVisState = {}; const instance = mount( @@ -1228,8 +1245,8 @@ describe('editor_frame', () => { .simulate('click'); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(1); + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1244,6 +1261,7 @@ describe('editor_frame', () => { }); it('should switch to best suggested visualization on field drop', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); const suggestionVisState = {}; const instance = mount( { .simulate('drop'); }); - expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1302,6 +1320,7 @@ describe('editor_frame', () => { }); it('should use the currently selected visualization if possible on field drop', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const suggestionVisState = {}; const instance = mount( { }); }); - expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1375,10 +1394,12 @@ describe('editor_frame', () => { }); it('should use the highest priority suggestion available', async () => { + mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']); const suggestionVisState = {}; const mockVisualization3 = { ...createMockVisualization(), id: 'testVis3', + getLayerIds: () => ['third'], visualizationTypes: [ { icon: 'empty', @@ -1460,7 +1481,7 @@ describe('editor_frame', () => { }); }); - expect(mockVisualization3.renderConfigPanel).toHaveBeenCalledWith( + expect(mockVisualization3.renderLayerConfigPanel).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ state: suggestionVisState, @@ -1633,13 +1654,16 @@ describe('editor_frame', () => { await waitForPromises(); expect(onChange).toHaveBeenCalledTimes(2); - (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: () => ({ - newState: true, - }), - datasourceId: 'testDatasource', + act(() => { + (instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: () => ({ + newState: true, + }), + datasourceId: 'testDatasource', + }); }); + await waitForPromises(); expect(onChange).toHaveBeenCalledTimes(3); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx index a2745818e19bb..3284f69b503c5 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -52,6 +52,8 @@ export interface EditorFrameProps { export function EditorFrame(props: EditorFrameProps) { const [state, dispatch] = useReducer(reducer, props, getInitialState); const { onError } = props; + const activeVisualization = + state.visualization.activeId && props.visualizationMap[state.visualization.activeId]; const allLoaded = Object.values(state.datasourceStates).every( ({ isLoading }) => typeof isLoading === 'boolean' && !isLoading @@ -125,7 +127,20 @@ export function EditorFrame(props: EditorFrameProps) { return newLayerId; }, - removeLayers: (layerIds: string[]) => { + + removeLayers(layerIds: string[]) { + if (activeVisualization && activeVisualization.removeLayer && state.visualization.state) { + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, + newState: layerIds.reduce( + (acc, layerId) => + activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc, + state.visualization.state + ), + }); + } + layerIds.forEach(layerId => { const layerDatasourceId = Object.entries(props.datasourceMap).find( ([datasourceId, datasource]) => @@ -158,16 +173,15 @@ export function EditorFrame(props: EditorFrameProps) { // Initialize visualization as soon as all datasources are ready useEffect(() => { - if (allLoaded && state.visualization.state === null && state.visualization.activeId !== null) { - const initialVisualizationState = props.visualizationMap[ - state.visualization.activeId - ].initialize(framePublicAPI); + if (allLoaded && state.visualization.state === null && activeVisualization) { + const initialVisualizationState = activeVisualization.initialize(framePublicAPI); dispatch({ type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: activeVisualization.id, newState: initialVisualizationState, }); } - }, [allLoaded, state.visualization.activeId, state.visualization.state]); + }, [allLoaded, activeVisualization, state.visualization.state]); // The frame needs to call onChange every time its internal state changes useEffect(() => { @@ -176,11 +190,7 @@ export function EditorFrame(props: EditorFrameProps) { ? props.datasourceMap[state.activeDatasourceId] : undefined; - const visualization = state.visualization.activeId - ? props.visualizationMap[state.visualization.activeId] - : undefined; - - if (!activeDatasource || !visualization) { + if (!activeDatasource || !activeVisualization) { return; } @@ -208,13 +218,14 @@ export function EditorFrame(props: EditorFrameProps) { }), {} ), - visualization, + visualization: activeVisualization, state, framePublicAPI, }); props.onChange({ filterableIndexPatterns: indexPatterns, doc }); }, [ + activeVisualization, state.datasourceStates, state.visualization, props.query, @@ -248,6 +259,7 @@ export function EditorFrame(props: EditorFrameProps) { configPanel={ allLoaded && ( ({ + id: datasourceId, + clearLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).map((id: string) => + id === layerId ? `${datasourceId}_clear_${layerId}` : id + ), + removeLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).filter((id: string) => id !== layerId), + insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], + }); + + const activeVisualization = { + clearLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).map((id: string) => (id === layerId ? `vis_clear_${layerId}` : id)), + removeLayer: (layerIds: unknown, layerId: string) => + (layerIds as string[]).filter((id: string) => id !== layerId), + getLayerIds: (layerIds: unknown) => layerIds as string[], + appendLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], + }; + + return { + state: { + activeDatasourceId: 'ds1', + datasourceStates: { + ds1: { + isLoading: false, + state: initialLayerIds.slice(0, 1), + }, + ds2: { + isLoading: false, + state: initialLayerIds.slice(1), + }, + }, + title: 'foo', + visualization: { + activeId: 'vis1', + state: initialLayerIds, + }, + }, + activeVisualization, + datasourceMap: { + ds1: testDatasource('ds1'), + ds2: testDatasource('ds2'), + }, + trackUiEvent, + }; +} + +describe('removeLayer', () => { + it('should clear the layer if it is the only layer', () => { + const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs(['layer1']); + const newState = removeLayer({ + activeVisualization, + datasourceMap, + layerId: 'layer1', + state, + trackUiEvent, + }); + + expect(newState.visualization.state).toEqual(['vis_clear_layer1']); + expect(newState.datasourceStates.ds1.state).toEqual(['ds1_clear_layer1']); + expect(newState.datasourceStates.ds2.state).toEqual([]); + expect(trackUiEvent).toHaveBeenCalledWith('layer_cleared'); + }); + + it('should remove the layer if it is not the only layer', () => { + const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs([ + 'layer1', + 'layer2', + ]); + const newState = removeLayer({ + activeVisualization, + datasourceMap, + layerId: 'layer1', + state, + trackUiEvent, + }); + + expect(newState.visualization.state).toEqual(['layer2']); + expect(newState.datasourceStates.ds1.state).toEqual([]); + expect(newState.datasourceStates.ds2.state).toEqual(['layer2']); + expect(trackUiEvent).toHaveBeenCalledWith('layer_removed'); + }); +}); + +describe('appendLayer', () => { + it('should add the layer to the datasource and visualization', () => { + const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs([ + 'layer1', + 'layer2', + ]); + const newState = appendLayer({ + activeDatasource: datasourceMap.ds1, + activeVisualization, + generateId: () => 'foo', + state, + trackUiEvent, + }); + + expect(newState.visualization.state).toEqual(['layer1', 'layer2', 'foo']); + expect(newState.datasourceStates.ds1.state).toEqual(['layer1', 'foo']); + expect(newState.datasourceStates.ds2.state).toEqual(['layer2']); + expect(trackUiEvent).toHaveBeenCalledWith('layer_added'); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts new file mode 100644 index 0000000000000..e0562e8ca8e11 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/layer_actions.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { EditorFrameState } from './state_management'; +import { Datasource, Visualization } from '../../types'; + +interface RemoveLayerOptions { + trackUiEvent: (name: string) => void; + state: EditorFrameState; + layerId: string; + activeVisualization: Pick; + datasourceMap: Record>; +} + +interface AppendLayerOptions { + trackUiEvent: (name: string) => void; + state: EditorFrameState; + generateId: () => string; + activeDatasource: Pick; + activeVisualization: Pick; +} + +export function removeLayer(opts: RemoveLayerOptions): EditorFrameState { + const { state, trackUiEvent: trackUiEvent, activeVisualization, layerId, datasourceMap } = opts; + const isOnlyLayer = activeVisualization + .getLayerIds(state.visualization.state) + .every(id => id === opts.layerId); + + trackUiEvent(isOnlyLayer ? 'layer_cleared' : 'layer_removed'); + + return { + ...state, + datasourceStates: _.mapValues(state.datasourceStates, (datasourceState, datasourceId) => { + const datasource = datasourceMap[datasourceId!]; + return { + ...datasourceState, + state: isOnlyLayer + ? datasource.clearLayer(datasourceState.state, layerId) + : datasource.removeLayer(datasourceState.state, layerId), + }; + }), + visualization: { + ...state.visualization, + state: + isOnlyLayer || !activeVisualization.removeLayer + ? activeVisualization.clearLayer(state.visualization.state, layerId) + : activeVisualization.removeLayer(state.visualization.state, layerId), + }, + }; +} + +export function appendLayer({ + trackUiEvent, + activeVisualization, + state, + generateId, + activeDatasource, +}: AppendLayerOptions): EditorFrameState { + trackUiEvent('layer_added'); + + if (!activeVisualization.appendLayer) { + return state; + } + + const layerId = generateId(); + + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [activeDatasource.id]: { + ...state.datasourceStates[activeDatasource.id], + state: activeDatasource.insertLayer( + state.datasourceStates[activeDatasource.id].state, + layerId + ), + }, + }, + visualization: { + ...state.visualization, + state: activeVisualization.appendLayer(state.visualization.state, layerId), + }, + }; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts index 5168059a33258..4aaf2a3ee9e81 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -119,6 +119,7 @@ describe('editor_frame state management', () => { }, { type: 'UPDATE_VISUALIZATION_STATE', + visualizationId: 'testVis', newState: newVisState, } ); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts index 78a9a13f48d6a..7d763bcac2cc9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -31,6 +31,13 @@ export type Action = type: 'UPDATE_TITLE'; title: string; } + | { + type: 'UPDATE_STATE'; + // Just for diagnostics, so we can determine what action + // caused this update. + subType: string; + updater: (prevState: EditorFrameState) => EditorFrameState; + } | { type: 'UPDATE_DATASOURCE_STATE'; updater: unknown | ((prevState: unknown) => unknown); @@ -39,6 +46,7 @@ export type Action = } | { type: 'UPDATE_VISUALIZATION_STATE'; + visualizationId: string; newState: unknown; clearStagedPreview?: boolean; } @@ -128,6 +136,8 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta return action.state; case 'UPDATE_TITLE': return { ...state, title: action.title }; + case 'UPDATE_STATE': + return action.updater(state); case 'UPDATE_LAYER': return { ...state, @@ -249,6 +259,12 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta if (!state.visualization.activeId) { throw new Error('Invariant: visualization state got updated without active visualization'); } + // This is a safeguard that prevents us from accidentally updating the + // wrong visualization. This occurs in some cases due to the uncoordinated + // way we manage state across plugins. + if (state.visualization.activeId !== action.visualizationId) { + return state; + } return { ...state, visualization: { diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts index 173f64c6292a8..eabcdfa7a24ab 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts @@ -10,7 +10,6 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Visualization, Datasource, - FramePublicAPI, TableChangeType, TableSuggestion, DatasourceSuggestion, @@ -130,7 +129,6 @@ function getVisualizationSuggestions( } export function switchToSuggestion( - frame: FramePublicAPI, dispatch: (action: Action) => void, suggestion: Pick< Suggestion, @@ -145,5 +143,6 @@ export function switchToSuggestion( datasourceState: suggestion.datasourceState, datasourceId: suggestion.datasourceId!, }; + dispatch(action); } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx index 2408d004689c9..46e226afe9c59 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -320,7 +320,7 @@ export function SuggestionPanel({ } else { trackSuggestionEvent(`position_${index}_of_${suggestions.length}`); setLastSelectedSuggestion(index); - switchToSuggestion(frame, dispatch, suggestion); + switchToSuggestion(dispatch, suggestion); } }} selected={index === lastSelectedSuggestion} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx index fb3fe770b315b..74dacd50d7a15 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public'; -import { Visualization, FramePublicAPI, TableSuggestion } from '../../types'; +import { FramePublicAPI, TableSuggestion, Visualization } from '../../types'; import { createMockVisualization, createMockDatasource, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index 05dcafcaeba31..1058ccd81d669 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -126,12 +126,7 @@ export function InnerWorkspacePanel({ if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); trackUiEvent(expression ? 'drop_non_empty' : 'drop_empty'); - switchToSuggestion( - framePublicAPI, - dispatch, - suggestionForDraggedField, - 'SWITCH_VISUALIZATION' - ); + switchToSuggestion(dispatch, suggestionForDraggedField, 'SWITCH_VISUALIZATION'); } } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx index 5df6cc8106d6a..7257647d5953e 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -14,12 +14,14 @@ import { import { embeddablePluginMock } from '../../../../../../src/plugins/embeddable/public/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks'; -import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; +import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types'; import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin'; export function createMockVisualization(): jest.Mocked { return { id: 'TEST_VIS', + clearLayer: jest.fn((state, _layerId) => state), + getLayerIds: jest.fn(_state => ['layer1']), visualizationTypes: [ { icon: 'empty', @@ -32,7 +34,7 @@ export function createMockVisualization(): jest.Mocked { getPersistableState: jest.fn(_state => _state), getSuggestions: jest.fn(_options => []), initialize: jest.fn((_frame, _state?) => ({})), - renderConfigPanel: jest.fn(), + renderLayerConfigPanel: jest.fn(), toExpression: jest.fn((_state, _frame) => null), toPreviewExpression: jest.fn((_state, _frame) => null), }; @@ -52,7 +54,8 @@ export function createMockDatasource(): DatasourceMock { return { id: 'mockindexpattern', - getDatasourceSuggestionsForField: jest.fn((_state, item) => []), + clearLayer: jest.fn((state, _layerId) => state), + getDatasourceSuggestionsForField: jest.fn((_state, _item) => []), getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), getPersistableState: jest.fn(), getPublicAPI: jest.fn().mockReturnValue(publicAPIMock), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx index 52f00a7cd4e9d..b04bd3a4e9be9 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -282,7 +282,7 @@ describe('IndexPattern Data Panel', () => { const parts = url.split('/'); const indexPatternTitle = parts[parts.length - 1]; return { - indexPatternTitle, + indexPatternTitle: `${indexPatternTitle}_testtitle`, existingFieldNames: ['field_1', 'field_2'].map( fieldName => `${indexPatternTitle}_${fieldName}` ), @@ -352,9 +352,9 @@ describe('IndexPattern Data Panel', () => { }); expect(nextState.existingFields).toEqual({ - aaa: { - aaa_field_1: true, - aaa_field_2: true, + a_testtitle: { + a_field_1: true, + a_field_2: true, }, }); }); @@ -369,13 +369,13 @@ describe('IndexPattern Data Panel', () => { }); expect(nextState.existingFields).toEqual({ - aaa: { - aaa_field_1: true, - aaa_field_2: true, + a_testtitle: { + a_field_1: true, + a_field_2: true, }, - bbb: { - bbb_field_1: true, - bbb_field_2: true, + b_testtitle: { + b_field_1: true, + b_field_2: true, }, }); }); @@ -397,7 +397,7 @@ describe('IndexPattern Data Panel', () => { expect(setState).toHaveBeenCalledTimes(2); expect(core.http.get).toHaveBeenCalledTimes(2); - expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/aaa', { + expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/a', { query: { fromDate: '2019-01-01', toDate: '2020-01-01', @@ -405,7 +405,7 @@ describe('IndexPattern Data Panel', () => { }, }); - expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/aaa', { + expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/a', { query: { fromDate: '2019-01-01', toDate: '2020-01-02', @@ -418,9 +418,9 @@ describe('IndexPattern Data Panel', () => { }); expect(nextState.existingFields).toEqual({ - aaa: { - aaa_field_1: true, - aaa_field_2: true, + a_testtitle: { + a_field_1: true, + a_field_2: true, }, }); }); @@ -436,7 +436,7 @@ describe('IndexPattern Data Panel', () => { expect(setState).toHaveBeenCalledTimes(2); - expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/aaa', { + expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/a', { query: { fromDate: '2019-01-01', toDate: '2020-01-01', @@ -444,7 +444,7 @@ describe('IndexPattern Data Panel', () => { }, }); - expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/bbb', { + expect(core.http.get).toHaveBeenCalledWith('/api/lens/existing_fields/b', { query: { fromDate: '2019-01-01', toDate: '2020-01-01', @@ -457,13 +457,13 @@ describe('IndexPattern Data Panel', () => { }); expect(nextState.existingFields).toEqual({ - aaa: { - aaa_field_1: true, - aaa_field_2: true, + a_testtitle: { + a_field_1: true, + a_field_2: true, }, - bbb: { - bbb_field_1: true, - bbb_field_2: true, + b_testtitle: { + b_field_1: true, + b_field_2: true, }, }); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 6a2f6234279c7..3231ab7d7ff12 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -109,6 +109,7 @@ export function IndexPatternDataPanel({ .sort((a, b) => a.localeCompare(b)) .filter(id => !!indexPatterns[id]) .map(id => ({ + id, title: indexPatterns[id].title, timeFieldName: indexPatterns[id].timeFieldName, })); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx index 85c1deb0ea7e1..e0e33ef03d3d1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx @@ -17,37 +17,37 @@ import { FieldIcon } from './field_icon'; describe('FieldIcon', () => { it('should render icons', () => { expect(shallow()).toMatchInlineSnapshot(` - - `); + + `); expect(shallow()).toMatchInlineSnapshot(` - - `); + + `); expect(shallow()).toMatchInlineSnapshot(` - - `); + + `); expect(shallow()).toMatchInlineSnapshot(` - - `); + + `); expect(shallow()).toMatchInlineSnapshot(` `); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx index f1e8db04860a7..796f200bffd97 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { ICON_TYPES, palettes, EuiIcon } from '@elastic/eui'; +import { ICON_TYPES, euiPaletteColorBlind, EuiIcon } from '@elastic/eui'; import classNames from 'classnames'; import { DataType } from '../types'; @@ -24,7 +24,7 @@ function getIconForDataType(dataType: string) { export function getColorForDataType(type: string) { const iconType = getIconForDataType(type); - const { colors } = palettes.euiPaletteColorBlind; + const colors = euiPaletteColorBlind(); const colorIndex = stringToNum(iconType) % colors.length; return colors[colorIndex]; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx index b58a2d8ca52c7..2426d7fc14b5d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -132,11 +132,7 @@ export function getIndexPatternDatasource({ ...state, layers: { ...state.layers, - [newLayerId]: { - indexPatternId: state.currentIndexPatternId, - columns: {}, - columnOrder: [], - }, + [newLayerId]: blankLayer(state.currentIndexPatternId), }, }; }, @@ -151,6 +147,16 @@ export function getIndexPatternDatasource({ }; }, + clearLayer(state: IndexPatternPrivateState, layerId: string) { + return { + ...state, + layers: { + ...state.layers, + [layerId]: blankLayer(state.currentIndexPatternId), + }, + }; + }, + getLayers(state: IndexPatternPrivateState) { return Object.keys(state.layers); }, @@ -280,3 +286,11 @@ export function getIndexPatternDatasource({ return indexPatternDatasource; } + +function blankLayer(indexPatternId: string) { + return { + indexPatternId, + columns: {}, + columnOrder: [], + }; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.test.tsx index 7441083550706..a470f5fc51cfb 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.test.tsx @@ -20,10 +20,10 @@ test('LensFieldIcon renders properly', () => { test('LensFieldIcon getColorForDataType for a valid type', () => { const color = getColorForDataType('date'); - expect(color).toEqual('#B0916F'); + expect(color).toEqual('#F19F58'); }); test('LensFieldIcon getColorForDataType for an invalid type', () => { const color = getColorForDataType('invalid'); - expect(color).toEqual('#1EA593'); + expect(color).toEqual('#5BBAA0'); }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.tsx index cd2bb69f6e580..2e6a5fcd8115f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/lens_field_icon.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { palettes } from '@elastic/eui'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { FieldIcon, typeToEuiIconMap } from '../../../../../../src/plugins/kibana_react/public'; import { DataType } from '../types'; import { normalizeOperationDataType } from './utils'; @@ -15,7 +15,7 @@ export function getColorForDataType(type: string) { if (iconMap) { return iconMap.color; } - return palettes.euiPaletteColorBlind.colors[0]; + return euiPaletteColorBlind()[0]; } export function LensFieldIcon({ type }: { type: DataType }) { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.test.ts index e180ab690d418..6bea13c32830f 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.test.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.test.ts @@ -551,7 +551,7 @@ describe('loader', () => { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any fetchJson: fetchJson as any, - indexPatterns: [{ title: 'a' }, { title: 'b' }, { title: 'c' }], + indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], setState, }); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts index 7f46f50786cf4..c196cb886e575 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts @@ -15,6 +15,7 @@ import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternField, + AggregationRestrictions, } from './types'; import { updateLayerIndexPattern } from './state_helpers'; import { DateRange, ExistingFields } from '../../common/types'; @@ -30,19 +31,7 @@ interface SavedIndexPatternAttributes extends SavedObjectAttributes { } interface SavedRestrictionsObject { - aggs: Record< - string, - Record< - string, - { - agg: string; - fixed_interval?: string; - calendar_interval?: string; - delay?: string; - time_zone?: string; - } - > - >; + aggs: Record; } type SetState = StateSetter; @@ -230,7 +219,7 @@ export async function syncExistingFields({ setState, }: { dateRange: DateRange; - indexPatterns: Array<{ title: string; timeFieldName?: string | null }>; + indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; fetchJson: HttpSetup['get']; setState: SetState; }) { @@ -245,7 +234,7 @@ export async function syncExistingFields({ query.timeFieldName = pattern.timeFieldName; } - return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.title}`, { + return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { query, }) as Promise; }) @@ -301,8 +290,9 @@ function fromSavedObject( newFields.forEach((field, index) => { const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {}; aggs.forEach(agg => { - if (typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]) { - restrictionsObj[agg] = typeMeta.aggs[agg][field.name]; + const restriction = typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]; + if (restriction) { + restrictionsObj[agg] = restriction; } }); if (Object.keys(restrictionsObj).length) { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx index 0bb653ac1e0c2..dbb6278352f09 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx @@ -322,7 +322,7 @@ function parseInterval(currentInterval: string) { }; } -function restrictedInterval(aggregationRestrictions?: AggregationRestrictions) { +function restrictedInterval(aggregationRestrictions?: Partial) { if (!aggregationRestrictions || !aggregationRestrictions.date_histogram) { return; } diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx index a891814bb0496..d21c6c74e1050 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx @@ -354,7 +354,7 @@ describe('terms', () => { expect(select.prop('value')).toEqual('alphabetical'); - expect(select.prop('options').map(({ value }) => value)).toEqual([ + expect(select.prop('options')!.map(({ value }) => value)).toEqual([ 'column$$$col2', 'alphabetical', ]); @@ -423,7 +423,7 @@ describe('terms', () => { .find(EuiSelect); expect(select.prop('value')).toEqual('asc'); - expect(select.prop('options').map(({ value }) => value)).toEqual(['asc', 'desc']); + expect(select.prop('options')!.map(({ value }) => value)).toEqual(['asc', 'desc']); }); it('should update state with the order direction value', () => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/types.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/types.ts index 50478515d19ce..e556ddda10679 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/types.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/types.ts @@ -20,18 +20,16 @@ export interface IndexPattern { >; } -export type AggregationRestrictions = Partial< - Record< - string, - { - agg: string; - interval?: number; - fixed_interval?: string; - calendar_interval?: string; - delay?: string; - time_zone?: string; - } - > +export type AggregationRestrictions = Record< + string, + { + agg?: string; + interval?: number; + fixed_interval?: string; + calendar_interval?: string; + delay?: string; + time_zone?: string; + } >; export interface IndexPatternField { @@ -41,7 +39,7 @@ export interface IndexPatternField { aggregatable: boolean; scripted?: boolean; searchable: boolean; - aggregationRestrictions?: AggregationRestrictions; + aggregationRestrictions?: Partial; } export interface IndexPatternLayer { diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx index ff2e55ac83dcc..a66239e5d30f6 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx @@ -38,6 +38,7 @@ describe('MetricConfigPanel', () => { const state = testState(); const component = mount( !op.isBucketed && op.dataType === 'number'; -export function MetricConfigPanel(props: VisualizationProps) { - const { state, frame } = props; - const [datasource] = Object.values(frame.datasourceLayers); - const [layerId] = Object.keys(frame.datasourceLayers); +export function MetricConfigPanel(props: VisualizationLayerConfigProps) { + const { state, frame, layerId } = props; + const datasource = frame.datasourceLayers[layerId]; return ( - - - - - - - - - + + + ); } diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts index a95b5a2b27631..c131612399cca 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts @@ -50,6 +50,22 @@ describe('metric_visualization', () => { }); }); + describe('#getLayerIds', () => { + it('returns the layer id', () => { + expect(metricVisualization.getLayerIds(exampleState())).toEqual(['l1']); + }); + }); + + describe('#clearLayer', () => { + it('returns a clean layer', () => { + (generateId as jest.Mock).mockReturnValueOnce('test-id1'); + expect(metricVisualization.clearLayer(exampleState(), 'l1')).toEqual({ + accessor: 'test-id1', + layerId: 'l1', + }); + }); + }); + describe('#getPersistableState', () => { it('persists the state as given', () => { expect(metricVisualization.getPersistableState(exampleState())).toEqual(exampleState()); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx index 00e945c0ce6e5..6714c05787837 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx @@ -54,6 +54,17 @@ export const metricVisualization: Visualization = { }, ], + clearLayer(state) { + return { + ...state, + accessor: generateId(), + }; + }, + + getLayerIds(state) { + return [state.layerId]; + }, + getDescription() { return { icon: chartMetricSVG, @@ -76,7 +87,7 @@ export const metricVisualization: Visualization = { getPersistableState: state => state, - renderConfigPanel: (domElement, props) => + renderLayerConfigPanel: (domElement, props) => render( diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts index f83157b2a8000..923e0aff5ae0e 100644 --- a/x-pack/legacy/plugins/lens/public/types.ts +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -135,6 +135,7 @@ export interface Datasource { insertLayer: (state: T, newLayerId: string) => T; removeLayer: (state: T, layerId: string) => T; + clearLayer: (state: T, layerId: string) => T; getLayers: (state: T) => string[]; renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; @@ -237,7 +238,8 @@ export interface LensMultiTable { }; } -export interface VisualizationProps { +export interface VisualizationLayerConfigProps { + layerId: string; dragDropContext: DragContextState; frame: FramePublicAPI; state: T; @@ -325,6 +327,18 @@ export interface Visualization { visualizationTypes: VisualizationType[]; + getLayerIds: (state: T) => string[]; + + clearLayer: (state: T, layerId: string) => T; + + removeLayer?: (state: T, layerId: string) => T; + + appendLayer?: (state: T, layerId: string) => T; + + getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined; + + renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerConfigProps) => void; + getDescription: ( state: T ) => { @@ -339,7 +353,7 @@ export interface Visualization { getPersistableState: (state: T) => P; - renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; + renderLayerConfigPanel: (domElement: Element, props: VisualizationLayerConfigProps) => void; toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap index 8df2d764c0208..76802e701b387 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap @@ -86,18 +86,18 @@ exports[`xy_expression XYChart component it renders area 1`] = ` "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { @@ -272,18 +272,18 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { @@ -458,18 +458,18 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { @@ -644,18 +644,18 @@ exports[`xy_expression XYChart component it renders line 1`] = ` "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { @@ -830,18 +830,18 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { @@ -1020,18 +1020,18 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { @@ -1210,18 +1210,18 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = "top": 0, }, "colors": Object { - "defaultVizColor": "#3185FC", + "defaultVizColor": "#6092C0", "vizColors": Array [ - "#1EA593", - "#2B70F7", - "#CE0060", - "#38007E", - "#FCA5D3", - "#F37020", - "#E49E29", - "#B0916F", - "#7B000B", - "#34130C", + "#5BBAA0", + "#6092C0", + "#D36086", + "#9170B8", + "#EEAFCF", + "#FAE181", + "#CDBD9D", + "#F19F58", + "#B46F5F", + "#E7664C", ], }, "crosshair": Object { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx index 5cdf1031a22b0..6ed827bc71c68 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -8,9 +8,9 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps } from '@elastic/eui'; -import { XYConfigPanel } from './xy_config_panel'; +import { XYConfigPanel, LayerContextMenu } from './xy_config_panel'; import { DatasourceDimensionPanelProps, Operation, FramePublicAPI } from '../types'; -import { State, XYState } from './types'; +import { State } from './types'; import { Position } from '@elastic/charts'; import { NativeRendererProps } from '../native_renderer'; import { generateId } from '../id_generator'; @@ -46,15 +46,6 @@ describe('XYConfigPanel', () => { .props(); } - function openComponentPopover(component: ReactWrapper, layerId: string) { - component - .find(`[data-test-subj="lnsXY_layer_${layerId}"]`) - .first() - .find(`[data-test-subj="lnsXY_layer_advanced"]`) - .first() - .simulate('click'); - } - beforeEach(() => { frame = createMockFramePublicAPI(); frame.datasourceLayers = { @@ -67,55 +58,55 @@ describe('XYConfigPanel', () => { test.skip('allows toggling the y axis gridlines', () => {}); test.skip('allows toggling the x axis gridlines', () => {}); - test('enables stacked chart types even when there is no split series', () => { - const state = testState(); - const component = mount( - - ); - - openComponentPopover(component, 'first'); - - const options = component - .find('[data-test-subj="lnsXY_seriesType"]') - .first() - .prop('options') as EuiButtonGroupProps['options']; + describe('LayerContextMenu', () => { + test('enables stacked chart types even when there is no split series', () => { + const state = testState(); + const component = mount( + + ); - expect(options!.map(({ id }) => id)).toEqual([ - 'bar', - 'bar_stacked', - 'line', - 'area', - 'area_stacked', - ]); + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; - expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); - }); + expect(options!.map(({ id }) => id)).toEqual([ + 'bar', + 'bar_stacked', + 'line', + 'area', + 'area_stacked', + ]); - test('shows only horizontal bar options when in horizontal mode', () => { - const state = testState(); - const component = mount( - - ); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + }); - openComponentPopover(component, 'first'); + test('shows only horizontal bar options when in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); - const options = component - .find('[data-test-subj="lnsXY_seriesType"]') - .first() - .prop('options') as EuiButtonGroupProps['options']; + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; - expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']); - expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']); + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + }); }); test('the x dimension panel accepts only bucketed operations', () => { @@ -123,6 +114,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { const state = testState(); const component = mount( { const state = testState(); const component = mount( { /> ); - openComponentPopover(component, 'first'); - const onRemove = component .find('[data-test-subj="lensXY_yDimensionPanel"]') .first() @@ -223,6 +215,7 @@ describe('XYConfigPanel', () => { const state = testState(); const component = mount( { ], }); }); - - describe('layers', () => { - it('adds layers', () => { - frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); - (generateId as jest.Mock).mockReturnValue('accessor'); - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - component - .find('[data-test-subj="lnsXY_layer_add"]') - .first() - .simulate('click'); - - expect(frame.addNewLayer).toHaveBeenCalled(); - expect(setState).toHaveBeenCalledTimes(1); - expect(generateId).toHaveBeenCalledTimes(4); - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [ - ...state.layers, - expect.objectContaining({ - layerId: 'newLayerId', - xAccessor: 'accessor', - accessors: ['accessor'], - splitAccessor: 'accessor', - }), - ], - }); - }); - - it('should use series type of existing layers if they all have the same', () => { - frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); - frame.datasourceLayers.second = createMockDatasource().publicAPIMock; - (generateId as jest.Mock).mockReturnValue('accessor'); - const setState = jest.fn(); - const state: XYState = { - ...testState(), - preferredSeriesType: 'bar', - layers: [ - { - seriesType: 'line', - layerId: 'first', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - { - seriesType: 'line', - layerId: 'second', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - ], - }; - const component = mount( - - ); - - component - .find('[data-test-subj="lnsXY_layer_add"]') - .first() - .simulate('click'); - - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [ - ...state.layers, - expect.objectContaining({ - seriesType: 'line', - }), - ], - }); - }); - - it('should use preffered series type if there are already various different layers', () => { - frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); - frame.datasourceLayers.second = createMockDatasource().publicAPIMock; - (generateId as jest.Mock).mockReturnValue('accessor'); - const setState = jest.fn(); - const state: XYState = { - ...testState(), - preferredSeriesType: 'bar', - layers: [ - { - seriesType: 'area', - layerId: 'first', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - { - seriesType: 'line', - layerId: 'second', - splitAccessor: 'baz', - xAccessor: 'foo', - accessors: ['bar'], - }, - ], - }; - const component = mount( - - ); - - component - .find('[data-test-subj="lnsXY_layer_add"]') - .first() - .simulate('click'); - - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [ - ...state.layers, - expect.objectContaining({ - seriesType: 'bar', - }), - ], - }); - }); - - it('removes layers', () => { - const setState = jest.fn(); - const state = testState(); - const component = mount( - - ); - - openComponentPopover(component, 'first'); - - component - .find('[data-test-subj="lnsXY_layer_remove"]') - .first() - .simulate('click'); - - expect(frame.removeLayers).toHaveBeenCalled(); - expect(setState).toHaveBeenCalledTimes(1); - expect(setState.mock.calls[0][0]).toMatchObject({ - layers: [], - }); - }); - }); }); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx index f59b1520dbbb4..dbcfa24395001 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -5,25 +5,11 @@ */ import _ from 'lodash'; -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiButton, - EuiButtonGroup, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiPanel, - EuiButtonIcon, - EuiPopover, - EuiSpacer, - EuiButtonEmpty, - EuiPopoverFooter, - EuiToolTip, -} from '@elastic/eui'; -import { State, SeriesType, LayerConfig, visualizationTypes } from './types'; -import { VisualizationProps, OperationMetadata } from '../types'; +import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; +import { State, SeriesType, visualizationTypes } from './types'; +import { VisualizationLayerConfigProps, OperationMetadata } from '../types'; import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; @@ -45,253 +31,140 @@ function updateLayer(state: State, layer: UnwrapArray, index: n }; } -function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { - return { - layerId, - seriesType, - xAccessor: generateId(), - accessors: [generateId()], - splitAccessor: generateId(), - }; -} +export function LayerContextMenu(props: VisualizationLayerConfigProps) { + const { state, layerId } = props; + const horizontalOnly = isHorizontalChart(state.layers); + const index = state.layers.findIndex(l => l.layerId === layerId); + const layer = state.layers[index]; -function LayerSettings({ - layer, - horizontalOnly, - setSeriesType, - removeLayer, -}: { - layer: LayerConfig; - horizontalOnly: boolean; - setSeriesType: (seriesType: SeriesType) => void; - removeLayer: () => void; -}) { - const [isOpen, setIsOpen] = useState(false); - const { icon } = visualizationTypes.find(c => c.id === layer.seriesType)!; + if (!layer) { + return null; + } return ( - setIsOpen(!isOpen)} - data-test-subj="lnsXY_layer_advanced" - /> - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - anchorPosition="leftUp" + - - isHorizontalSeries(t.id as SeriesType) === horizontalOnly) - .map(t => ({ - id: t.id, - label: t.label, - iconType: t.icon || 'empty', - }))} - idSelected={layer.seriesType} - onChange={seriesType => { - trackUiEvent('xy_change_layer_display'); - setSeriesType(seriesType as SeriesType); - }} - isIconOnly - buttonSize="compressed" - /> - - - - {i18n.translate('xpack.lens.xyChart.deleteLayer', { - defaultMessage: 'Delete layer', - })} - - - + name="chartType" + className="eui-displayInlineBlock" + data-test-subj="lnsXY_seriesType" + options={visualizationTypes + .filter(t => isHorizontalSeries(t.id as SeriesType) === horizontalOnly) + .map(t => ({ + id: t.id, + label: t.label, + iconType: t.icon || 'empty', + }))} + idSelected={layer.seriesType} + onChange={seriesType => { + trackUiEvent('xy_change_layer_display'); + props.setState( + updateLayer(state, { ...layer, seriesType: seriesType as SeriesType }, index) + ); + }} + isIconOnly + buttonSize="compressed" + /> + ); } -export function XYConfigPanel(props: VisualizationProps) { - const { state, setState, frame } = props; - const horizontalOnly = isHorizontalChart(state.layers); - - return ( - - {state.layers.map((layer, index) => ( - - - - - setState(updateLayer(state, { ...layer, seriesType }, index)) - } - removeLayer={() => { - trackUiEvent('xy_layer_removed'); - frame.removeLayers([layer.layerId]); - setState({ ...state, layers: state.layers.filter(l => l !== layer) }); - }} - /> - - - - - - +export function XYConfigPanel(props: VisualizationLayerConfigProps) { + const { state, setState, frame, layerId } = props; + const index = props.state.layers.findIndex(l => l.layerId === layerId); - + if (index < 0) { + return null; + } - - - - - - setState( - updateLayer( - state, - { - ...layer, - accessors: [...layer.accessors, generateId()], - }, - index - ) - ) - } - onRemove={accessor => - setState( - updateLayer( - state, - { - ...layer, - accessors: layer.accessors.filter(col => col !== accessor), - }, - index - ) - ) - } - filterOperations={isNumericMetric} - data-test-subj="lensXY_yDimensionPanel" - testSubj="lensXY_yDimensionPanel" - layerId={layer.layerId} - /> - - - - - - ))} + const layer = props.state.layers[index]; - - - { - trackUiEvent('xy_layer_added'); - const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); - setState({ - ...state, - layers: [ - ...state.layers, - newLayerState( - usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, - frame.addNewLayer() - ), - ], - }); - }} - iconType="plusInCircleFilled" - /> - - - + return ( + <> + + + + + + setState( + updateLayer( + state, + { + ...layer, + accessors: [...layer.accessors, generateId()], + }, + index + ) + ) + } + onRemove={accessor => + setState( + updateLayer( + state, + { + ...layer, + accessors: layer.accessors.filter(col => col !== accessor), + }, + index + ) + ) + } + filterOperations={isNumericMetric} + data-test-subj="lensXY_yDimensionPanel" + testSubj="lensXY_yDimensionPanel" + layerId={layer.layerId} + /> + + + + + ); } diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts index db28e76f82946..89794ec74eaec 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -137,6 +137,54 @@ describe('xy_visualization', () => { }); }); + describe('#removeLayer', () => { + it('removes the specified layer', () => { + const prevState: State = { + ...exampleState(), + layers: [ + ...exampleState().layers, + { + layerId: 'second', + seriesType: 'area', + splitAccessor: 'e', + xAccessor: 'f', + accessors: ['g', 'h'], + }, + ], + }; + + expect(xyVisualization.removeLayer!(prevState, 'second')).toEqual(exampleState()); + }); + }); + + describe('#appendLayer', () => { + it('adds a layer', () => { + const layers = xyVisualization.appendLayer!(exampleState(), 'foo').layers; + expect(layers.length).toEqual(exampleState().layers.length + 1); + expect(layers[layers.length - 1]).toMatchObject({ layerId: 'foo' }); + }); + }); + + describe('#clearLayer', () => { + it('clears the specified layer', () => { + (generateId as jest.Mock).mockReturnValue('test_empty_id'); + const layer = xyVisualization.clearLayer(exampleState(), 'first').layers[0]; + expect(layer).toMatchObject({ + accessors: ['test_empty_id'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'test_empty_id', + xAccessor: 'test_empty_id', + }); + }); + }); + + describe('#getLayerIds', () => { + it('returns layerids', () => { + expect(xyVisualization.getLayerIds(exampleState())).toEqual(['first']); + }); + }); + describe('#toExpression', () => { let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx index 5ba77cb39d5f8..75d6fcc7d160b 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -11,9 +11,9 @@ import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; -import { XYConfigPanel } from './xy_config_panel'; +import { XYConfigPanel, LayerContextMenu } from './xy_config_panel'; import { Visualization } from '../types'; -import { State, PersistableState, SeriesType, visualizationTypes } from './types'; +import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types'; import { toExpression, toPreviewExpression } from './to_expression'; import { generateId } from '../id_generator'; import chartBarStackedSVG from '../assets/chart_bar_stacked.svg'; @@ -67,6 +67,40 @@ export const xyVisualization: Visualization = { visualizationTypes, + getLayerIds(state) { + return state.layers.map(l => l.layerId); + }, + + removeLayer(state, layerId) { + return { + ...state, + layers: state.layers.filter(l => l.layerId !== layerId), + }; + }, + + appendLayer(state, layerId) { + const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); + return { + ...state, + layers: [ + ...state.layers, + newLayerState( + usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, + layerId + ), + ], + }; + }, + + clearLayer(state, layerId) { + return { + ...state, + layers: state.layers.map(l => + l.layerId !== layerId ? l : newLayerState(state.preferredSeriesType, layerId) + ), + }; + }, + getDescription(state) { const { icon, label } = getDescription(state); const chartLabel = i18n.translate('xpack.lens.xyVisualization.chartLabel', { @@ -113,7 +147,7 @@ export const xyVisualization: Visualization = { getPersistableState: state => state, - renderConfigPanel: (domElement, props) => + renderLayerConfigPanel: (domElement, props) => render( @@ -121,6 +155,30 @@ export const xyVisualization: Visualization = { domElement ), + getLayerContextMenuIcon({ state, layerId }) { + const layer = state.layers.find(l => l.layerId === layerId); + return visualizationTypes.find(t => t.id === layer?.seriesType)?.icon; + }, + + renderLayerContextMenu(domElement, props) { + render( + + + , + domElement + ); + }, + toExpression, toPreviewExpression, }; + +function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { + return { + layerId, + seriesType, + xAccessor: generateId(), + accessors: [generateId()], + splitAccessor: generateId(), + }; +} diff --git a/x-pack/legacy/plugins/lens/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx index 0223b90c37046..f80d52248b484 100644 --- a/x-pack/legacy/plugins/lens/server/plugin.tsx +++ b/x-pack/legacy/plugins/lens/server/plugin.tsx @@ -5,28 +5,51 @@ */ import { Server, KibanaConfig } from 'src/legacy/server/kbn_server'; -import { Plugin, CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; +import { Plugin, CoreSetup, CoreStart, SavedObjectsLegacyService } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; import { setupRoutes } from './routes'; -import { registerLensUsageCollector, initializeLensTelemetry } from './usage'; +import { + registerLensUsageCollector, + initializeLensTelemetry, + scheduleLensTelemetry, +} from './usage'; export interface PluginSetupContract { savedObjects: SavedObjectsLegacyService; usageCollection: UsageCollectionSetup; config: KibanaConfig; server: Server; + taskManager: TaskManagerSetupContract; } +export interface PluginStartContract { + server: Server; + taskManager: TaskManagerStartContract; +} + +const taskManagerStartContract$ = new Subject(); + export class LensServer implements Plugin<{}, {}, {}, {}> { setup(core: CoreSetup, plugins: PluginSetupContract) { setupRoutes(core, plugins); - registerLensUsageCollector(plugins.usageCollection, plugins.server); - initializeLensTelemetry(core, plugins.server); - + registerLensUsageCollector( + plugins.usageCollection, + taskManagerStartContract$.pipe(first()).toPromise() + ); + initializeLensTelemetry(plugins.server, plugins.taskManager); return {}; } - start() { + start(core: CoreStart, plugins: PluginStartContract) { + scheduleLensTelemetry(plugins.server, plugins.taskManager); + taskManagerStartContract$.next(plugins.taskManager); + taskManagerStartContract$.complete(); return {}; } diff --git a/x-pack/legacy/plugins/lens/server/routes/existing_fields.test.ts b/x-pack/legacy/plugins/lens/server/routes/existing_fields.test.ts index 1647dcccaed3c..1f19671826807 100644 --- a/x-pack/legacy/plugins/lens/server/routes/existing_fields.test.ts +++ b/x-pack/legacy/plugins/lens/server/routes/existing_fields.test.ts @@ -4,24 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { existingFields } from './existing_fields'; +import { existingFields, Field, buildFieldList } from './existing_fields'; describe('existingFields', () => { - function field(name: string, parent?: string) { + function field(opts: string | Partial): Field { + const obj = typeof opts === 'object' ? opts : {}; + const name = (typeof opts === 'string' ? opts : opts.name) || 'test'; + return { name, - subType: parent ? { multi: { parent } } : undefined, - aggregatable: true, - esTypes: [], - readFromDocValues: true, - searchable: true, - type: 'string', + isScript: false, + isAlias: false, + path: name.split('.'), + ...obj, }; } + function indexPattern(_source: unknown, fields: unknown = {}) { + return { _source, fields }; + } + it('should handle root level fields', () => { const result = existingFields( - [{ _source: { foo: 'bar' } }, { _source: { baz: 0 } }], + [indexPattern({ foo: 'bar' }), indexPattern({ baz: 0 })], [field('foo'), field('bar'), field('baz')] ); @@ -30,7 +35,7 @@ describe('existingFields', () => { it('should handle arrays of objects', () => { const result = existingFields( - [{ _source: { stuff: [{ foo: 'bar' }, { baz: 0 }] } }], + [indexPattern({ stuff: [{ foo: 'bar' }, { baz: 0 }] })], [field('stuff.foo'), field('stuff.bar'), field('stuff.baz')] ); @@ -38,14 +43,14 @@ describe('existingFields', () => { }); it('should handle basic arrays', () => { - const result = existingFields([{ _source: { stuff: ['heyo', 'there'] } }], [field('stuff')]); + const result = existingFields([indexPattern({ stuff: ['heyo', 'there'] })], [field('stuff')]); expect(result).toEqual(['stuff']); }); it('should handle deep object structures', () => { const result = existingFields( - [{ _source: { geo: { coordinates: { lat: 40, lon: -77 } } } }], + [indexPattern({ geo: { coordinates: { lat: 40, lon: -77 } } })], [field('geo.coordinates')] ); @@ -54,19 +59,97 @@ describe('existingFields', () => { it('should be false if it hits a positive leaf before the end of the path', () => { const result = existingFields( - [{ _source: { geo: { coordinates: 32 } } }], + [indexPattern({ geo: { coordinates: 32 } })], [field('geo.coordinates.lat')] ); expect(result).toEqual([]); }); - it('should prefer parent to name', () => { + it('should use path, not name', () => { const result = existingFields( - [{ _source: { stuff: [{ foo: 'bar' }, { baz: 0 }] } }], - [field('goober', 'stuff.foo'), field('soup', 'stuff.bar'), field('pea', 'stuff.baz')] + [indexPattern({ stuff: [{ foo: 'bar' }, { baz: 0 }] })], + [field({ name: 'goober', path: ['stuff', 'foo'] })] ); - expect(result).toEqual(['goober', 'pea']); + expect(result).toEqual(['goober']); + }); + + it('supports scripted fields', () => { + const result = existingFields( + [indexPattern({}, { bar: 'scriptvalue' })], + [field({ name: 'baz', isScript: true, path: ['bar'] })] + ); + + expect(result).toEqual(['baz']); + }); +}); + +describe('buildFieldList', () => { + const indexPattern = { + id: '', + type: 'indexpattern', + attributes: { + title: 'testpattern', + fields: JSON.stringify([ + { name: 'foo', scripted: true, lang: 'painless', script: '2+2' }, + { name: 'bar' }, + { name: '@bar' }, + { name: 'baz' }, + ]), + }, + references: [], + }; + + const mappings = { + testpattern: { + mappings: { + properties: { + '@bar': { + type: 'alias', + path: 'bar', + }, + }, + }, + }, + }; + + const fieldDescriptors = [ + { + name: 'baz', + subType: { multi: { parent: 'a.b.c' } }, + }, + ]; + + it('uses field descriptors to determine the path', () => { + const fields = buildFieldList(indexPattern, mappings, fieldDescriptors); + expect(fields.find(f => f.name === 'baz')).toMatchObject({ + isAlias: false, + isScript: false, + name: 'baz', + path: ['a', 'b', 'c'], + }); + }); + + it('uses aliases to determine the path', () => { + const fields = buildFieldList(indexPattern, mappings, fieldDescriptors); + expect(fields.find(f => f.isAlias)).toMatchObject({ + isAlias: true, + isScript: false, + name: '@bar', + path: ['bar'], + }); + }); + + it('supports scripted fields', () => { + const fields = buildFieldList(indexPattern, mappings, fieldDescriptors); + expect(fields.find(f => f.isScript)).toMatchObject({ + isAlias: false, + isScript: true, + name: 'foo', + path: ['foo'], + lang: 'painless', + script: '2+2', + }); }); }); diff --git a/x-pack/legacy/plugins/lens/server/routes/existing_fields.ts b/x-pack/legacy/plugins/lens/server/routes/existing_fields.ts index ad1af966983fb..fbbcf9973431b 100644 --- a/x-pack/legacy/plugins/lens/server/routes/existing_fields.ts +++ b/x-pack/legacy/plugins/lens/server/routes/existing_fields.ts @@ -6,28 +6,50 @@ import Boom from 'boom'; import { schema } from '@kbn/config-schema'; -import { SearchResponse } from 'elasticsearch'; import _ from 'lodash'; -import { IScopedClusterClient } from 'src/core/server'; +import { IScopedClusterClient, SavedObject, RequestHandlerContext } from 'src/core/server'; import { CoreSetup } from 'src/core/server'; import { BASE_API_URL } from '../../common'; -import { FieldDescriptor, IndexPatternsFetcher } from '../../../../../../src/plugins/data/server'; +import { IndexPatternsFetcher } from '../../../../../../src/plugins/data/server'; /** * The number of docs to sample to determine field empty status. */ const SAMPLE_SIZE = 500; -type Document = Record; +interface MappingResult { + [indexPatternTitle: string]: { + mappings: { + properties: Record; + }; + }; +} + +interface FieldDescriptor { + name: string; + subType?: { multi?: { parent?: string } }; +} + +export interface Field { + name: string; + isScript: boolean; + isAlias: boolean; + path: string[]; + lang?: string; + script?: string; +} + +// TODO: Pull this from kibana advanced settings +const metaFields = ['_source', '_id', '_type', '_index', '_score']; export async function existingFieldsRoute(setup: CoreSetup) { const router = setup.http.createRouter(); router.get( { - path: `${BASE_API_URL}/existing_fields/{indexPatternTitle}`, + path: `${BASE_API_URL}/existing_fields/{indexPatternId}`, validate: { params: schema.object({ - indexPatternTitle: schema.string(), + indexPatternId: schema.string(), }), query: schema.object({ fromDate: schema.maybe(schema.string()), @@ -37,31 +59,13 @@ export async function existingFieldsRoute(setup: CoreSetup) { }, }, async (context, req, res) => { - const { indexPatternTitle } = req.params; - const requestClient = context.core.elasticsearch.dataClient; - const indexPatternsFetcher = new IndexPatternsFetcher(requestClient.callAsCurrentUser); - const { fromDate, toDate, timeFieldName } = req.query; - try { - const fields = await indexPatternsFetcher.getFieldsForWildcard({ - pattern: indexPatternTitle, - // TODO: Pull this from kibana advanced settings - metaFields: ['_source', '_id', '_type', '_index', '_score'], - }); - - const results = await fetchIndexPatternStats({ - fromDate, - toDate, - client: requestClient, - index: indexPatternTitle, - timeFieldName, - }); - return res.ok({ - body: { - indexPatternTitle, - existingFieldNames: existingFields(results.hits.hits, fields), - }, + body: await fetchFieldExistence({ + ...req.query, + ...req.params, + context, + }), }); } catch (e) { if (e.status === 404) { @@ -82,6 +86,172 @@ export async function existingFieldsRoute(setup: CoreSetup) { ); } +async function fetchFieldExistence({ + context, + indexPatternId, + fromDate, + toDate, + timeFieldName, +}: { + indexPatternId: string; + context: RequestHandlerContext; + fromDate?: string; + toDate?: string; + timeFieldName?: string; +}) { + const { + indexPattern, + indexPatternTitle, + mappings, + fieldDescriptors, + } = await fetchIndexPatternDefinition(indexPatternId, context); + + const fields = buildFieldList(indexPattern, mappings, fieldDescriptors); + + const docs = await fetchIndexPatternStats({ + fromDate, + toDate, + client: context.core.elasticsearch.dataClient, + index: indexPatternTitle, + timeFieldName: timeFieldName || indexPattern.attributes.timeFieldName, + fields, + }); + + return { + indexPatternTitle, + existingFieldNames: existingFields(docs, fields), + }; +} + +async function fetchIndexPatternDefinition(indexPatternId: string, context: RequestHandlerContext) { + const savedObjectsClient = context.core.savedObjects.client; + const requestClient = context.core.elasticsearch.dataClient; + const indexPattern = await savedObjectsClient.get('index-pattern', indexPatternId); + const indexPatternTitle = indexPattern.attributes.title; + // TODO: maybe don't use IndexPatternsFetcher at all, since we're only using it + // to look up field values in the resulting documents. We can accomplish the same + // using the mappings which we're also fetching here. + const indexPatternsFetcher = new IndexPatternsFetcher(requestClient.callAsCurrentUser); + const [mappings, fieldDescriptors] = await Promise.all([ + requestClient.callAsCurrentUser('indices.getMapping', { + index: indexPatternTitle, + }), + + indexPatternsFetcher.getFieldsForWildcard({ + pattern: indexPatternTitle, + // TODO: Pull this from kibana advanced settings + metaFields, + }), + ]); + + return { + indexPattern, + indexPatternTitle, + mappings, + fieldDescriptors, + }; +} + +/** + * Exported only for unit tests. + */ +export function buildFieldList( + indexPattern: SavedObject, + mappings: MappingResult, + fieldDescriptors: FieldDescriptor[] +): Field[] { + const aliasMap = Object.entries(Object.values(mappings)[0].mappings.properties) + .map(([name, v]) => ({ ...v, name })) + .filter(f => f.type === 'alias') + .reduce((acc, f) => { + acc[f.name] = f.path; + return acc; + }, {} as Record); + + const descriptorMap = fieldDescriptors.reduce((acc, f) => { + acc[f.name] = f; + return acc; + }, {} as Record); + + return JSON.parse(indexPattern.attributes.fields).map( + (field: { name: string; lang: string; scripted?: boolean; script?: string }) => { + const path = + aliasMap[field.name] || descriptorMap[field.name]?.subType?.multi?.parent || field.name; + return { + name: field.name, + isScript: !!field.scripted, + isAlias: !!aliasMap[field.name], + path: path.split('.'), + lang: field.lang, + script: field.script, + }; + } + ); +} + +async function fetchIndexPatternStats({ + client, + index, + timeFieldName, + fromDate, + toDate, + fields, +}: { + client: IScopedClusterClient; + index: string; + timeFieldName?: string; + fromDate?: string; + toDate?: string; + fields: Field[]; +}) { + let query; + + if (timeFieldName && fromDate && toDate) { + query = { + bool: { + filter: [ + { + range: { + [timeFieldName]: { + gte: fromDate, + lte: toDate, + }, + }, + }, + ], + }, + }; + } else { + query = { + match_all: {}, + }; + } + const viableFields = fields.filter( + f => !f.isScript && !f.isAlias && !metaFields.includes(f.name) + ); + const scriptedFields = fields.filter(f => f.isScript); + + const result = await client.callAsCurrentUser('search', { + index, + body: { + size: SAMPLE_SIZE, + _source: viableFields.map(f => f.name), + query, + script_fields: scriptedFields.reduce((acc, field) => { + acc[field.name] = { + script: { + lang: field.lang, + source: field.script, + }, + }; + return acc; + }, {} as Record), + }, + }); + + return result.hits.hits; +} + function exists(obj: unknown, path: string[], i = 0): boolean { if (obj == null) { return false; @@ -103,21 +273,13 @@ function exists(obj: unknown, path: string[], i = 0): boolean { } /** - * Exported for testing purposes only. + * Exported only for unit tests. */ export function existingFields( - docs: Array<{ _source: Document }>, - fields: FieldDescriptor[] + docs: Array<{ _source: unknown; fields: unknown }>, + fields: Field[] ): string[] { - const allFields = fields.map(field => { - const parent = field.subType && field.subType.multi && field.subType.multi.parent; - return { - name: field.name, - parent, - path: (parent || field.name).split('.'), - }; - }); - const missingFields = new Set(allFields); + const missingFields = new Set(fields); for (const doc of docs) { if (missingFields.size === 0) { @@ -125,53 +287,11 @@ export function existingFields( } missingFields.forEach(field => { - if (exists(doc._source, field.path)) { + if (exists(field.isScript ? doc.fields : doc._source, field.path)) { missingFields.delete(field); } }); } - return allFields.filter(field => !missingFields.has(field)).map(f => f.name); -} - -async function fetchIndexPatternStats({ - client, - fromDate, - index, - toDate, - timeFieldName, -}: { - client: IScopedClusterClient; - fromDate?: string; - index: string; - toDate?: string; - timeFieldName?: string; -}) { - const body = - !timeFieldName || !fromDate || !toDate - ? {} - : { - query: { - bool: { - filter: [ - { - range: { - [timeFieldName]: { - gte: fromDate, - lte: toDate, - }, - }, - }, - ], - }, - }, - }; - - return (await client.callAsCurrentUser('search', { - index, - body: { - ...body, - size: SAMPLE_SIZE, - }, - })) as SearchResponse; + return fields.filter(field => !missingFields.has(field)).map(f => f.name); } diff --git a/x-pack/legacy/plugins/lens/server/usage/collectors.ts b/x-pack/legacy/plugins/lens/server/usage/collectors.ts index 274b72c33e59a..666b3718d5125 100644 --- a/x-pack/legacy/plugins/lens/server/usage/collectors.ts +++ b/x-pack/legacy/plugins/lens/server/usage/collectors.ts @@ -6,32 +6,25 @@ import moment from 'moment'; import { get } from 'lodash'; -import { Server } from 'src/legacy/server/kbn_server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { TaskManagerStartContract } from '../../../../../plugins/task_manager/server'; import { LensUsage, LensTelemetryState } from './types'; -export function registerLensUsageCollector(usageCollection: UsageCollectionSetup, server: Server) { +export function registerLensUsageCollector( + usageCollection: UsageCollectionSetup, + taskManager: Promise +) { let isCollectorReady = false; - async function determineIfTaskManagerIsReady() { - let isReady = false; - try { - isReady = await isTaskManagerReady(server); - } catch (err) {} // eslint-disable-line - - if (isReady) { - isCollectorReady = true; - } else { - setTimeout(determineIfTaskManagerIsReady, 500); - } - } - determineIfTaskManagerIsReady(); - + taskManager.then(() => { + // mark lensUsageCollector as ready to collect when the TaskManager is ready + isCollectorReady = true; + }); const lensUsageCollector = usageCollection.makeUsageCollector({ type: 'lens', fetch: async (): Promise => { try { - const docs = await getLatestTaskState(server); + const docs = await getLatestTaskState(await taskManager); // get the accumulated state from the recurring task const state: LensTelemetryState = get(docs, '[0].state'); @@ -73,17 +66,7 @@ function addEvents(prevEvents: Record, newEvents: Record Promise; -export function initializeLensTelemetry(core: CoreSetup, server: Server) { - registerLensTelemetryTask(core, server); - scheduleTasks(server); -} - -function registerLensTelemetryTask(core: CoreSetup, server: Server) { - const taskManager = server.plugins.task_manager; - +export function initializeLensTelemetry(server: Server, taskManager?: TaskManagerSetupContract) { if (!taskManager) { server.log(['debug', 'telemetry'], `Task manager is not available`); - return; + } else { + registerLensTelemetryTask(server, taskManager); } +} +export function scheduleLensTelemetry(server: Server, taskManager?: TaskManagerStartContract) { + if (taskManager) { + scheduleTasks(server, taskManager); + } +} + +function registerLensTelemetryTask(server: Server, taskManager: TaskManagerSetupContract) { taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Lens telemetry fetch task', @@ -62,17 +68,11 @@ function registerLensTelemetryTask(core: CoreSetup, server: Server) { }); } -function scheduleTasks(server: Server) { - const taskManager = server.plugins.task_manager; +function scheduleTasks(server: Server, taskManager: TaskManagerStartContract) { const { kbnServer } = (server.plugins.xpack_main as XPackMainPlugin & { status: { plugin: { kbnServer: KbnServer } }; }).status.plugin; - if (!taskManager) { - server.log(['debug', 'telemetry'], `Task manager is not available`); - return; - } - kbnServer.afterPluginsInit(() => { // The code block below can't await directly within "afterPluginsInit" // callback due to circular dependency The server isn't "ready" until diff --git a/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/__snapshots__/pipeline_editor.test.js.snap b/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/__snapshots__/pipeline_editor.test.js.snap index dc51c066a8cb9..74f109df382cd 100644 --- a/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/__snapshots__/pipeline_editor.test.js.snap +++ b/x-pack/legacy/plugins/logstash/public/components/pipeline_editor/__snapshots__/pipeline_editor.test.js.snap @@ -48,11 +48,9 @@ exports[`PipelineEditor component includes required error message for falsy pipe labelType="label" > `; diff --git a/x-pack/legacy/plugins/maps/common/constants.js b/x-pack/legacy/plugins/maps/common/constants.js index 6e7776d43f4d4..b3cefbf5c0b41 100644 --- a/x-pack/legacy/plugins/maps/common/constants.js +++ b/x-pack/legacy/plugins/maps/common/constants.js @@ -140,3 +140,12 @@ export const LAYER_STYLE_TYPE = { VECTOR: 'VECTOR', HEATMAP: 'HEATMAP', }; + +export const COLOR_MAP_TYPE = { + CATEGORICAL: 'CATEGORICAL', + ORDINAL: 'ORDINAL', +}; + +export const COLOR_PALETTE_MAX_SIZE = 10; + +export const CATEGORICAL_DATA_TYPES = ['string', 'ip', 'boolean']; diff --git a/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap b/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap index 98e6fa0517f58..2dc355513ece2 100644 --- a/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/components/__snapshots__/geometry_filter_form.test.js.snap @@ -13,8 +13,6 @@ exports[`should not render relation select when geo field is geo_point 1`] = ` > @@ -92,8 +90,6 @@ exports[`should not show "within" relation when filter geometry is not closed 1` > @@ -151,9 +147,6 @@ exports[`should not show "within" relation when filter geometry is not closed 1` > @@ -283,8 +274,6 @@ exports[`should render relation select when geo field is geo_shape 1`] = ` > @@ -342,9 +331,6 @@ exports[`should render relation select when geo field is geo_shape 1`] = ` > { + indexPattern.fields.getByType(dataType).forEach(field => { + if (field.aggregatable) { + aggFields.push(field); + } + }); + }); + return aggFields.map(field => { + return this.createField({ fieldName: field.name }); + }); + } catch (error) { + //error surfaces in the LayerTOC UI + return []; + } + } + async getFields() { try { const indexPattern = await this.getIndexPattern(); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js index bf7267e9c5858..b9d8ae86c5850 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.js @@ -107,6 +107,10 @@ export class AbstractVectorSource extends AbstractSource { return [...(await this.getDateFields()), ...(await this.getNumberFields())]; } + async getCategoricalFields() { + return []; + } + async getLeftJoinFields() { return []; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js index 8aa32fa7e09c0..cc840d552e659 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js @@ -9,13 +9,14 @@ import React from 'react'; import { vislibColorMaps } from 'ui/vislib/components/color/colormaps'; import { getLegendColors, getColor } from 'ui/vis/map/color_util'; import { ColorGradient } from './components/color_gradient'; -import { palettes } from '@elastic/eui/lib/services'; +import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import tinycolor from 'tinycolor2'; import chroma from 'chroma-js'; +import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; const GRADIENT_INTERVALS = 8; -export const DEFAULT_FILL_COLORS = palettes.euiPaletteColorBlind.colors; +export const DEFAULT_FILL_COLORS = euiPaletteColorBlind(); export const DEFAULT_LINE_COLORS = [ ...DEFAULT_FILL_COLORS.map(color => tinycolor(color) @@ -51,6 +52,9 @@ export function getHexColorRangeStrings(colorRampName, numberColors = GRADIENT_I } export function getColorRampCenterColor(colorRampName) { + if (!colorRampName) { + return null; + } const colorRamp = getColorRamp(colorRampName); const centerIndex = Math.floor(colorRamp.value.length / 2); return getColor(colorRamp.value, centerIndex); @@ -58,7 +62,10 @@ export function getColorRampCenterColor(colorRampName) { // Returns an array of color stops // [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) { +export function getOrdinalColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) { + if (!colorRampName) { + return null; + } return getHexColorRangeStrings(colorRampName, numberColors).reduce( (accu, stopColor, idx, srcArr) => { const stopNumber = idx / srcArr.length; // number between 0 and 1, increasing as index increases @@ -84,3 +91,62 @@ export function getLinearGradient(colorStrings) { } return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; } + +const COLOR_PALETTES_CONFIGS = [ + { + id: 'palette_0', + colors: DEFAULT_FILL_COLORS.slice(0, COLOR_PALETTE_MAX_SIZE), + }, + { + id: 'palette_1', + colors: [ + '#a6cee3', + '#1f78b4', + '#b2df8a', + '#33a02c', + '#fb9a99', + '#e31a1c', + '#fdbf6f', + '#ff7f00', + '#cab2d6', + '#6a3d9a', + ], + }, + { + id: 'palette_2', + colors: [ + '#8dd3c7', + '#ffffb3', + '#bebada', + '#fb8072', + '#80b1d3', + '#fdb462', + '#b3de69', + '#fccde5', + '#d9d9d9', + '#bc80bd', + ], + }, +]; + +export function getColorPalette(paletteId) { + const palette = COLOR_PALETTES_CONFIGS.find(palette => palette.id === paletteId); + return palette ? palette.colors : null; +} + +export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map(palette => { + const paletteDisplay = palette.colors.map(color => { + const style = { + backgroundColor: color, + width: '10%', + position: 'relative', + height: '100%', + display: 'inline-block', + }; + return
 
; + }); + return { + value: palette.id, + inputDisplay:
{paletteDisplay}
, + }; +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js index 8826c771fab19..1d7fbeb996915 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js @@ -7,7 +7,7 @@ import { COLOR_GRADIENTS, getColorRampCenterColor, - getColorRampStops, + getOrdinalColorRampStops, getHexColorRangeStrings, getLinearGradient, getRGBColorRangeStrings, @@ -59,7 +59,7 @@ describe('getColorRampCenterColor', () => { describe('getColorRampStops', () => { it('Should create color stops for color ramp', () => { - expect(getColorRampStops('Blues')).toEqual([ + expect(getOrdinalColorRampStops('Blues')).toEqual([ 0, '#f7faff', 0.125, diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js index 0b4a52997c00e..1dd219d4c4cad 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js @@ -11,7 +11,7 @@ import { HeatmapStyleEditor } from './components/heatmap_style_editor'; import { HeatmapLegend } from './components/legend/heatmap_legend'; import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants'; import { LAYER_STYLE_TYPE } from '../../../../common/constants'; -import { getColorRampStops } from '../color_utils'; +import { getOrdinalColorRampStops } from '../color_utils'; import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; @@ -81,7 +81,7 @@ export class HeatmapStyle extends AbstractStyle { const { colorRampName } = this._descriptor; if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getColorRampStops(colorRampName); + const colorStops = getOrdinalColorRampStops(colorRampName); mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js new file mode 100644 index 0000000000000..242b71522f9a2 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_map_select.js @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; + +import { EuiSuperSelect, EuiSpacer } from '@elastic/eui'; +import { ColorStopsOrdinal } from './color_stops_ordinal'; +import { COLOR_MAP_TYPE } from '../../../../../../common/constants'; +import { ColorStopsCategorical } from './color_stops_categorical'; + +const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP'; + +export class ColorMapSelect extends Component { + state = { + selected: '', + }; + + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.customColorMap === prevState.prevPropsCustomColorMap) { + return null; + } + + return { + prevPropsCustomColorMap: nextProps.customColorMap, // reset tracker to latest value + customColorMap: nextProps.customColorMap, // reset customColorMap to latest value + }; + } + + _onColorMapSelect = selectedValue => { + const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP; + this.props.onChange({ + color: useCustomColorMap ? null : selectedValue, + useCustomColorMap, + type: this.props.colorMapType, + }); + }; + + _onCustomColorMapChange = ({ colorStops, isInvalid }) => { + // Manage invalid custom color map in local state + if (isInvalid) { + const newState = { + customColorMap: colorStops, + }; + this.setState(newState); + return; + } + + this.props.onChange({ + useCustomColorMap: true, + customColorMap: colorStops, + type: this.props.colorMapType, + }); + }; + + _renderColorStopsInput() { + let colorStopsInput; + if (this.props.useCustomColorMap) { + if (this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL) { + colorStopsInput = ( + + + + + ); + } else if (this.props.colorMapType === COLOR_MAP_TYPE.CATEGORICAL) { + colorStopsInput = ( + + + + + ); + } + } + return colorStopsInput; + } + + render() { + const colorStopsInput = this._renderColorStopsInput(); + const colorMapOptionsWithCustom = [ + { + value: CUSTOM_COLOR_MAP, + inputDisplay: this.props.customOptionLabel, + }, + ...this.props.colorMapOptions, + ]; + + let valueOfSelected; + if (this.props.useCustomColorMap) { + valueOfSelected = CUSTOM_COLOR_MAP; + } else { + valueOfSelected = this.props.colorMapOptions.find(option => option.value === this.props.color) + ? this.props.color + : ''; + } + + return ( + + + {colorStopsInput} + + ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_ramp_select.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_ramp_select.js deleted file mode 100644 index c2dd51a0182e3..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_ramp_select.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; - -import { EuiSuperSelect, EuiSpacer } from '@elastic/eui'; -import { COLOR_GRADIENTS } from '../../../color_utils'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ColorStops } from './color_stops'; - -const CUSTOM_COLOR_RAMP = 'CUSTOM_COLOR_RAMP'; - -export class ColorRampSelect extends Component { - state = {}; - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.customColorRamp !== prevState.prevPropsCustomColorRamp) { - return { - prevPropsCustomColorRamp: nextProps.customColorRamp, // reset tracker to latest value - customColorRamp: nextProps.customColorRamp, // reset customColorRamp to latest value - }; - } - - return null; - } - - _onColorRampSelect = selectedValue => { - const useCustomColorRamp = selectedValue === CUSTOM_COLOR_RAMP; - this.props.onChange({ - color: useCustomColorRamp ? null : selectedValue, - useCustomColorRamp, - }); - }; - - _onCustomColorRampChange = ({ colorStops, isInvalid }) => { - // Manage invalid custom color ramp in local state - if (isInvalid) { - this.setState({ customColorRamp: colorStops }); - return; - } - - this.props.onChange({ - customColorRamp: colorStops, - }); - }; - - render() { - const { - color, - onChange, // eslint-disable-line no-unused-vars - useCustomColorRamp, - customColorRamp, // eslint-disable-line no-unused-vars - ...rest - } = this.props; - - let colorStopsInput; - if (useCustomColorRamp) { - colorStopsInput = ( - - - - - ); - } - - const colorRampOptions = [ - { - value: CUSTOM_COLOR_RAMP, - inputDisplay: ( - - ), - }, - ...COLOR_GRADIENTS, - ]; - - return ( - - - {colorStopsInput} - - ); - } -} - -ColorRampSelect.propTypes = { - color: PropTypes.string, - onChange: PropTypes.func.isRequired, - useCustomColorRamp: PropTypes.bool, - customColorRamp: PropTypes.array, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js index d523cf5870912..6b403ff61532d 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js @@ -6,66 +6,106 @@ import _ from 'lodash'; import React from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiColorPicker, - EuiFormRow, - EuiFieldNumber, - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, -} from '@elastic/eui'; -import { addRow, removeRow, isColorInvalid, isStopInvalid, isInvalid } from './color_stops_utils'; - -const DEFAULT_COLOR = '#FF0000'; - -export const ColorStops = ({ colorStops = [{ stop: 0, color: DEFAULT_COLOR }], onChange }) => { +import { removeRow, isColorInvalid } from './color_stops_utils'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiColorPicker, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; + +function getColorStopRow({ index, errors, stopInput, colorInput, deleteButton, onAdd }) { + return ( + +
+ + {stopInput} + {colorInput} + +
+ {deleteButton} + +
+
+
+ ); +} + +export function getDeleteButton(onRemove) { + return ( + + ); +} + +export const ColorStops = ({ + onChange, + colorStops, + isStopsInvalid, + sanitizeStopInput, + getStopError, + renderStopInput, + addNewRow, + canDeleteStop, +}) => { function getStopInput(stop, index) { const onStopChange = e => { const newColorStops = _.cloneDeep(colorStops); - const sanitizedValue = parseFloat(e.target.value); - newColorStops[index].stop = isNaN(sanitizedValue) ? '' : sanitizedValue; + newColorStops[index].stop = sanitizeStopInput(e.target.value); + const invalid = isStopsInvalid(newColorStops); onChange({ colorStops: newColorStops, - isInvalid: isInvalid(newColorStops), + isInvalid: invalid, }); }; - let error; - if (isStopInvalid(stop)) { - error = 'Stop must be a number'; - } else if (index !== 0 && colorStops[index - 1].stop >= stop) { - error = 'Stop must be greater than previous stop value'; - } - + const error = getStopError(stop, index); return { stopError: error, - stopInput: ( - - ), + stopInput: renderStopInput(stop, onStopChange, index), + }; + } + + function getColorInput(onColorChange, color) { + return { + colorError: isColorInvalid(color) + ? i18n.translate('xpack.maps.styles.colorStops.hexWarningLabel', { + defaultMessage: 'Color must provide a valid hex value', + }) + : undefined, + colorInput: , }; } - function getColorInput(color, index) { + const rows = colorStops.map((colorStop, index) => { const onColorChange = color => { const newColorStops = _.cloneDeep(colorStops); newColorStops[index].color = color; onChange({ colorStops: newColorStops, - isInvalid: isInvalid(newColorStops), + isInvalid: isStopsInvalid(newColorStops), }); }; - return { - colorError: isColorInvalid(color) ? 'Color must provide a valid hex value' : undefined, - colorInput: , - }; - } - - const rows = colorStops.map((colorStop, index) => { const { stopError, stopInput } = getStopInput(colorStop.stop, index); - const { colorError, colorInput } = getColorInput(colorStop.color, index); + const { colorError, colorInput } = getColorInput(onColorChange, colorStop.color); const errors = []; if (stopError) { errors.push(stopError); @@ -74,82 +114,28 @@ export const ColorStops = ({ colorStops = [{ stop: 0, color: DEFAULT_COLOR }], o errors.push(colorError); } - const onRemove = () => { - const newColorStops = removeRow(colorStops, index); - onChange({ - colorStops: newColorStops, - isInvalid: isInvalid(newColorStops), - }); - }; - const onAdd = () => { - const newColorStops = addRow(colorStops, index); - + const newColorStops = addNewRow(colorStops, index); onChange({ colorStops: newColorStops, - isInvalid: isInvalid(newColorStops), + isInvalid: isStopsInvalid(newColorStops), }); }; let deleteButton; - if (colorStops.length > 1) { - deleteButton = ( - - ); + if (canDeleteStop(colorStops, index)) { + const onRemove = () => { + const newColorStops = removeRow(colorStops, index); + onChange({ + colorStops: newColorStops, + isInvalid: isStopsInvalid(newColorStops), + }); + }; + deleteButton = getDeleteButton(onRemove); } - return ( - -
- - {stopInput} - {colorInput} - -
- {deleteButton} - -
-
-
- ); + return getColorStopRow({ index, errors, stopInput, colorInput, deleteButton, onAdd }); }); return
{rows}
; }; - -ColorStops.propTypes = { - /** - * Array of { stop, color }. - * Stops are numbers in strictly ascending order. - * The range is from the given stop number (inclusive) to the next stop number (exclusive). - * Colors are color hex strings (3 or 6 character). - */ - colorStops: PropTypes.arrayOf( - PropTypes.shape({ - stopKey: PropTypes.number, - color: PropTypes.string, - }) - ), - /** - * Callback for when the color stops changes. Called with { colorStops, isInvalid } - */ - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js new file mode 100644 index 0000000000000..d5948d5539bae --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_categorical.js @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { EuiFieldText } from '@elastic/eui'; +import { + addCategoricalRow, + isCategoricalStopsInvalid, + getOtherCategoryLabel, + DEFAULT_CUSTOM_COLOR, + DEFAULT_NEXT_COLOR, +} from './color_stops_utils'; +import { i18n } from '@kbn/i18n'; +import { ColorStops } from './color_stops'; + +export const ColorStopsCategorical = ({ + colorStops = [ + { stop: null, color: DEFAULT_CUSTOM_COLOR }, //first stop is the "other" color + { stop: '', color: DEFAULT_NEXT_COLOR }, + ], + onChange, +}) => { + const sanitizeStopInput = value => { + return value; + }; + + const getStopError = (stop, index) => { + let count = 0; + for (let i = 1; i < colorStops.length; i++) { + if (colorStops[i].stop === stop && i !== index) { + count++; + } + } + + return count + ? i18n.translate('xpack.maps.styles.colorStops.categoricalStop.noDupesWarningLabel', { + defaultMessage: 'Stop values must be unique', + }) + : null; + }; + + const renderStopInput = (stop, onStopChange, index) => { + const stopValue = typeof stop === 'string' ? stop : ''; + if (index === 0) { + return ( + + ); + } else { + return ( + + ); + } + }; + + const canDeleteStop = (colorStops, index) => { + return colorStops.length > 2 && index !== 0; + }; + + return ( + + ); +}; + +ColorStopsCategorical.propTypes = { + /** + * Array of { stop, color }. + * Stops are any strings + * Stops cannot include duplicates + * Colors are color hex strings (3 or 6 character). + */ + colorStops: PropTypes.arrayOf( + PropTypes.shape({ + stopKey: PropTypes.number, + color: PropTypes.string, + }) + ), + /** + * Callback for when the color stops changes. Called with { colorStops, isInvalid } + */ + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js new file mode 100644 index 0000000000000..61fbb376ad601 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_ordinal.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ColorStops } from './color_stops'; +import { EuiFieldNumber } from '@elastic/eui'; +import { + addOrdinalRow, + isOrdinalStopInvalid, + isOrdinalStopsInvalid, + DEFAULT_CUSTOM_COLOR, +} from './color_stops_utils'; +import { i18n } from '@kbn/i18n'; + +export const ColorStopsOrdinal = ({ + colorStops = [{ stop: 0, color: DEFAULT_CUSTOM_COLOR }], + onChange, +}) => { + const sanitizeStopInput = value => { + const sanitizedValue = parseFloat(value); + return isNaN(sanitizedValue) ? '' : sanitizedValue; + }; + + const getStopError = (stop, index) => { + let error; + if (isOrdinalStopInvalid(stop)) { + error = i18n.translate('xpack.maps.styles.colorStops.ordinalStop.numberWarningLabel', { + defaultMessage: 'Stop must be a number', + }); + } else if (index !== 0 && colorStops[index - 1].stop >= stop) { + error = i18n.translate( + 'xpack.maps.styles.colorStops.ordinalStop.numberOrderingWarningLabel', + { + defaultMessage: 'Stop must be greater than previous stop value', + } + ); + } + return error; + }; + + const renderStopInput = (stop, onStopChange) => { + return ( + + ); + }; + + const canDeleteStop = colorStops => { + return colorStops.length > 1; + }; + + return ( + + ); +}; + +ColorStopsOrdinal.propTypes = { + /** + * Array of { stop, color }. + * Stops are numbers in strictly ascending order. + * The range is from the given stop number (inclusive) to the next stop number (exclusive). + * Colors are color hex strings (3 or 6 character). + */ + colorStops: PropTypes.arrayOf( + PropTypes.shape({ + stopKey: PropTypes.number, + color: PropTypes.string, + }) + ), + /** + * Callback for when the color stops changes. Called with { colorStops, isInvalid } + */ + onChange: PropTypes.func.isRequired, +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js index fb0a25cf7d5ee..3eaa6acf435dc 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops_utils.js @@ -5,6 +5,11 @@ */ import { isValidHex } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; + +export const DEFAULT_CUSTOM_COLOR = '#FF0000'; +export const DEFAULT_NEXT_COLOR = '#00FF00'; export function removeRow(colorStops, index) { if (colorStops.length === 1) { @@ -14,7 +19,7 @@ export function removeRow(colorStops, index) { return [...colorStops.slice(0, index), ...colorStops.slice(index + 1)]; } -export function addRow(colorStops, index) { +export function addOrdinalRow(colorStops, index) { const currentStop = colorStops[index].stop; let delta = 1; if (index === colorStops.length - 1) { @@ -28,10 +33,20 @@ export function addRow(colorStops, index) { const nextStop = colorStops[index + 1].stop; delta = (nextStop - currentStop) / 2; } + const nextValue = currentStop + delta; + return addRow(colorStops, index, nextValue); +} + +export function addCategoricalRow(colorStops, index) { + const currentStop = colorStops[index].stop; + const nextValue = currentStop === '' ? currentStop + 'a' : ''; + return addRow(colorStops, index, nextValue); +} +function addRow(colorStops, index, nextValue) { const newRow = { - stop: currentStop + delta, - color: '#FF0000', + stop: nextValue, + color: DEFAULT_CUSTOM_COLOR, }; return [...colorStops.slice(0, index + 1), newRow, ...colorStops.slice(index + 1)]; } @@ -40,11 +55,18 @@ export function isColorInvalid(color) { return !isValidHex(color) || color === ''; } -export function isStopInvalid(stop) { +export function isOrdinalStopInvalid(stop) { return stop === '' || isNaN(stop); } -export function isInvalid(colorStops) { +export function isCategoricalStopsInvalid(colorStops) { + const nonDefaults = colorStops.slice(1); // + const values = nonDefaults.map(stop => stop.stop); + const uniques = _.uniq(values); + return values.length !== uniques.length; +} + +export function isOrdinalStopsInvalid(colorStops) { return colorStops.some((colorStop, index) => { // expect stops to be in ascending order let isDescending = false; @@ -53,6 +75,12 @@ export function isInvalid(colorStops) { isDescending = prevStop >= colorStop.stop; } - return isColorInvalid(colorStop.color) || isStopInvalid(colorStop.stop) || isDescending; + return isColorInvalid(colorStop.color) || isOrdinalStopInvalid(colorStop.stop) || isDescending; + }); +} + +export function getOtherCategoryLabel() { + return i18n.translate('xpack.maps.styles.categorical.otherCategoryLabel', { + defaultMessage: 'Other', }); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js index 5e0f7434b04d0..7994f84386a8a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js @@ -7,56 +7,146 @@ import _ from 'lodash'; import React, { Fragment } from 'react'; import { FieldSelect } from '../field_select'; -import { ColorRampSelect } from './color_ramp_select'; +import { ColorMapSelect } from './color_map_select'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants'; +import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils'; +import { i18n } from '@kbn/i18n'; -export function DynamicColorForm({ - fields, - onDynamicStyleChange, - staticDynamicSelect, - styleProperty, -}) { - const styleOptions = styleProperty.getOptions(); - - const onFieldChange = ({ field }) => { - onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); +export class DynamicColorForm extends React.Component { + state = { + colorMapType: COLOR_MAP_TYPE.ORDINAL, }; - const onColorChange = colorOptions => { - onDynamicStyleChange(styleProperty.getStyleName(), { - ...styleOptions, - ...colorOptions, - }); - }; + constructor() { + super(); + this._isMounted = false; + } - let colorRampSelect; - if (styleOptions.field && styleOptions.field.name) { - colorRampSelect = ( - - ); + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + this._loadColorMapType(); + } + + componentDidUpdate() { + this._loadColorMapType(); + } + + async _loadColorMapType() { + const field = this.props.styleProperty.getField(); + if (!field) { + return; + } + const dataType = await field.getDataType(); + const colorMapType = CATEGORICAL_DATA_TYPES.includes(dataType) + ? COLOR_MAP_TYPE.CATEGORICAL + : COLOR_MAP_TYPE.ORDINAL; + if (this._isMounted && this.state.colorMapType !== colorMapType) { + this.setState({ colorMapType }, () => { + const options = this.props.styleProperty.getOptions(); + this.props.onDynamicStyleChange(this.props.styleProperty.getStyleName(), { + ...options, + type: colorMapType, + }); + }); + } } - return ( - - - {staticDynamicSelect} - - - - - - {colorRampSelect} - - ); + _getColorSelector() { + const { onDynamicStyleChange, styleProperty } = this.props; + const styleOptions = styleProperty.getOptions(); + + if (!styleOptions.field || !styleOptions.field.name) { + return; + } + + let colorSelect; + const onColorChange = colorOptions => { + const newColorOptions = { + type: colorOptions.type, + }; + if (colorOptions.type === COLOR_MAP_TYPE.ORDINAL) { + newColorOptions.useCustomColorRamp = colorOptions.useCustomColorMap; + newColorOptions.customColorRamp = colorOptions.customColorMap; + newColorOptions.color = colorOptions.color; + } else { + newColorOptions.useCustomColorPalette = colorOptions.useCustomColorMap; + newColorOptions.customColorPalette = colorOptions.customColorMap; + newColorOptions.colorCategory = colorOptions.color; + } + + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + ...newColorOptions, + }); + }; + + if (this.state.colorMapType === COLOR_MAP_TYPE.ORDINAL) { + const customOptionLabel = i18n.translate('xpack.maps.style.customColorRampLabel', { + defaultMessage: 'Custom color ramp', + }); + colorSelect = ( + onColorChange(options)} + colorMapType={COLOR_MAP_TYPE.ORDINAL} + color={styleOptions.color} + customColorMap={styleOptions.customColorRamp} + useCustomColorMap={_.get(styleOptions, 'useCustomColorRamp', false)} + compressed + /> + ); + } else if (this.state.colorMapType === COLOR_MAP_TYPE.CATEGORICAL) { + const customOptionLabel = i18n.translate('xpack.maps.style.customColorPaletteLabel', { + defaultMessage: 'Custom color palette', + }); + colorSelect = ( + onColorChange(options)} + colorMapType={COLOR_MAP_TYPE.CATEGORICAL} + color={styleOptions.colorCategory} + customColorMap={styleOptions.customColorPalette} + useCustomColorMap={_.get(styleOptions, 'useCustomColorPalette', false)} + compressed + /> + ); + } + return colorSelect; + } + + render() { + const { fields, onDynamicStyleChange, staticDynamicSelect, styleProperty } = this.props; + const styleOptions = styleProperty.getOptions(); + const onFieldChange = options => { + const field = options.field; + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); + }; + + const colorSelect = this._getColorSelector(); + + return ( + + + {staticDynamicSelect} + + + + + + {colorSelect} + + ); + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js index 325fc28f92051..16cfd34c95ab3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js @@ -42,6 +42,14 @@ export function getVectorStyleLabel(styleName) { return i18n.translate('xpack.maps.styles.vector.labelSizeLabel', { defaultMessage: 'Label size', }); + case VECTOR_STYLES.LABEL_BORDER_COLOR: + return i18n.translate('xpack.maps.styles.vector.labelBorderColorLabel', { + defaultMessage: 'Label border color', + }); + case VECTOR_STYLES.LABEL_BORDER_SIZE: + return i18n.translate('xpack.maps.styles.vector.labelBorderWidthLabel', { + defaultMessage: 'Label border width', + }); default: return styleName; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js new file mode 100644 index 0000000000000..7d06e8b530011 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { LABEL_BORDER_SIZES, VECTOR_STYLES } from '../../vector_style_defaults'; +import { getVectorStyleLabel } from '../get_vector_style_label'; +import { i18n } from '@kbn/i18n'; + +const options = [ + { + value: LABEL_BORDER_SIZES.NONE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.noneLabel', { + defaultMessage: 'None', + }), + }, + { + value: LABEL_BORDER_SIZES.SMALL, + text: i18n.translate('xpack.maps.styles.labelBorderSize.smallLabel', { + defaultMessage: 'Small', + }), + }, + { + value: LABEL_BORDER_SIZES.MEDIUM, + text: i18n.translate('xpack.maps.styles.labelBorderSize.mediumLabel', { + defaultMessage: 'Medium', + }), + }, + { + value: LABEL_BORDER_SIZES.LARGE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.largeLabel', { + defaultMessage: 'Large', + }), + }, +]; + +export function VectorStyleLabelBorderSizeEditor({ handlePropertyChange, styleProperty }) { + function onChange(e) { + const styleDescriptor = { + options: { size: e.target.value }, + }; + handlePropertyChange(styleProperty.getStyleName(), styleDescriptor); + } + + return ( + + + + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap index 57368b52a2bce..5837a80ec3083 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Renders CircleIcon with correct styles when isPointOnly 1`] = ` +exports[`Renders CircleIcon 1`] = ` `; -exports[`Renders LineIcon with correct styles when isLineOnly 1`] = ` +exports[`Renders LineIcon 1`] = ` `; -exports[`Renders PolygonIcon with correct styles when not line only or not point only 1`] = ` +exports[`Renders PolygonIcon 1`] = ` `; -exports[`Renders SymbolIcon with correct styles when isPointOnly and symbolId provided 1`] = ` +exports[`Renders SymbolIcon 1`] = ` ; - } +export function VectorIcon({ fillColor, isPointsOnly, isLinesOnly, strokeColor, symbolId }) { + if (isLinesOnly) { const style = { - stroke: this.props.getColorForProperty(VECTOR_STYLES.LINE_COLOR, false), - strokeWidth: '1px', - fill: this.props.getColorForProperty(VECTOR_STYLES.FILL_COLOR, false), + stroke: strokeColor, + strokeWidth: '4px', }; + return ; + } - if (!this.state.isPointsOnly) { - return ; - } + const style = { + stroke: strokeColor, + strokeWidth: '1px', + fill: fillColor, + }; - if (!this.props.symbolId) { - return ; - } + if (!isPointsOnly) { + return ; + } - return ( - - ); + if (!symbolId) { + return ; } + + return ( + + ); } VectorIcon.propTypes = { - getColorForProperty: PropTypes.func.isRequired, + fillColor: PropTypes.string, + isPointsOnly: PropTypes.bool.isRequired, + isLinesOnly: PropTypes.bool.isRequired, + strokeColor: PropTypes.string.isRequired, symbolId: PropTypes.string, - loadIsPointsOnly: PropTypes.func.isRequired, - loadIsLinesOnly: PropTypes.func.isRequired, }; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js index ee0058a6ef1aa..9d1a4d75beba2 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js @@ -8,113 +8,51 @@ import React from 'react'; import { shallow } from 'enzyme'; import { VectorIcon } from './vector_icon'; -import { VectorStyle } from '../../vector_style'; -import { extractColorFromStyleProperty } from './extract_color_from_style_property'; -import { VECTOR_STYLES } from '../../vector_style_defaults'; -let isPointsOnly = false; -let isLinesOnly = false; -const styles = { - fillColor: { - type: VectorStyle.STYLE_TYPE.STATIC, - options: { - color: '#ff0000', - }, - }, - lineColor: { - type: VectorStyle.STYLE_TYPE.DYNAMIC, - options: { - color: 'Blues', - field: { - name: 'prop1', - }, - }, - }, -}; - -const defaultProps = { - getColorForProperty: (styleProperty, isLinesOnly) => { - if (isLinesOnly) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'grey'); - } - - if (styleProperty === VECTOR_STYLES.LINE_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'none'); - } else if (styleProperty === VECTOR_STYLES.FILL_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.FILL_COLOR], 'grey'); - } else { - //unexpected - console.error('Cannot return color for properties other then line or fill color'); - } - }, - - loadIsPointsOnly: () => { - return isPointsOnly; - }, - loadIsLinesOnly: () => { - return isLinesOnly; - }, -}; - -function configureIsLinesOnly() { - isLinesOnly = true; - isPointsOnly = false; -} - -function configureIsPointsOnly() { - isLinesOnly = false; - isPointsOnly = true; -} - -function configureNotLineOrPointOnly() { - isLinesOnly = false; - isPointsOnly = false; -} - -test('Renders PolygonIcon with correct styles when not line only or not point only', async () => { - configureNotLineOrPointOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders PolygonIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders LineIcon with correct styles when isLineOnly', async () => { - configureIsLinesOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders LineIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders CircleIcon with correct styles when isPointOnly', async () => { - configureIsPointsOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders CircleIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders SymbolIcon with correct styles when isPointOnly and symbolId provided', async () => { - configureIsPointsOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders SymbolIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js index df302c42d48ed..a7e98c83468ae 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js @@ -4,57 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import React, { Component, Fragment } from 'react'; - -export class VectorStyleLegend extends Component { - state = { - styles: [], - }; - - componentDidMount() { - this._isMounted = true; - this._prevStyleDescriptors = undefined; - this._loadRows(); - } - - componentDidUpdate() { - this._loadRows(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - _loadRows = _.debounce(async () => { - const styles = await this.props.getLegendDetailStyleProperties(); - const styleDescriptorPromises = styles.map(async style => { - return { - type: style.getStyleName(), - options: style.getOptions(), - fieldMeta: style.getFieldMeta(), - label: await style.getField().getLabel(), - }; - }); - - const styleDescriptors = await Promise.all(styleDescriptorPromises); - if (this._isMounted && !_.isEqual(styleDescriptors, this._prevStyleDescriptors)) { - this._prevStyleDescriptors = styleDescriptors; - this.setState({ styles: styles }); - } - }, 100); - - render() { - return this.state.styles.map(style => { - return ( - - {style.renderLegendDetailRow({ - loadIsLinesOnly: this.props.loadIsLinesOnly, - loadIsPointsOnly: this.props.loadIsPointsOnly, - symbolId: this.props.symbolId, - })} - - ); - }); - } +import React, { Fragment } from 'react'; + +export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId }) { + return styles.map(style => { + return ( + + {style.renderLegendDetailRow({ + isLinesOnly, + isPointsOnly, + symbolId, + })} + + ); + }); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/field_meta_options_popover.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js similarity index 98% rename from x-pack/legacy/plugins/maps/public/layers/styles/vector/components/field_meta_options_popover.js rename to x-pack/legacy/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js index 471403e1f3999..dee333f163960 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/field_meta_options_popover.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/ordinal_field_meta_options_popover.js @@ -31,7 +31,7 @@ function getIsEnableToggleLabel(styleName) { } } -export class FieldMetaOptionsPopover extends Component { +export class OrdinalFieldMetaOptionsPopover extends Component { state = { isPopoverOpen: false, }; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js index 1ac8edfb2cc69..e8b544d8ede16 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js @@ -5,7 +5,6 @@ */ import React, { Component, Fragment } from 'react'; -import { FieldMetaOptionsPopover } from './field_meta_options_popover'; import { getVectorStyleLabel } from './get_vector_style_label'; import { EuiFormRow, EuiSelect } from '@elastic/eui'; import { VectorStyle } from '../vector_style'; @@ -80,12 +79,9 @@ export class StylePropEditor extends Component { } render() { - const fieldMetaOptionsPopover = this.props.styleProperty.isDynamic() ? ( - - ) : null; + const fieldMetaOptionsPopover = this.props.styleProperty.renderFieldMetaPopover( + this._onFieldMetaOptionsChange + ); return ( { this.setState({ selectedFeature }); }; @@ -156,7 +153,7 @@ export class VectorStyleEditor extends Component { onStaticStyleChange={this._onStaticStyleChange} onDynamicStyleChange={this._onDynamicStyleChange} styleProperty={this.props.styleProperties[VECTOR_STYLES.FILL_COLOR]} - fields={this._getOrdinalFields()} + fields={this._getOrdinalAndCategoricalFields()} defaultStaticStyleOptions={ this.state.defaultStaticProperties[VECTOR_STYLES.FILL_COLOR].options } @@ -174,7 +171,7 @@ export class VectorStyleEditor extends Component { onStaticStyleChange={this._onStaticStyleChange} onDynamicStyleChange={this._onDynamicStyleChange} styleProperty={this.props.styleProperties[VECTOR_STYLES.LINE_COLOR]} - fields={this._getOrdinalFields()} + fields={this._getOrdinalAndCategoricalFields()} defaultStaticStyleOptions={ this.state.defaultStaticProperties[VECTOR_STYLES.LINE_COLOR].options } @@ -241,7 +238,7 @@ export class VectorStyleEditor extends Component { onStaticStyleChange={this._onStaticStyleChange} onDynamicStyleChange={this._onDynamicStyleChange} styleProperty={this.props.styleProperties[VECTOR_STYLES.LABEL_COLOR]} - fields={this._getOrdinalFields()} + fields={this._getOrdinalAndCategoricalFields()} defaultStaticStyleOptions={ this.state.defaultStaticProperties[VECTOR_STYLES.LABEL_COLOR].options } @@ -264,6 +261,27 @@ export class VectorStyleEditor extends Component { } /> + + + + + + ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap index 26e36cb97a791..3b3cade87a4ad 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -1,6 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Should render categorical legend 1`] = ` +exports[`Should render categorical legend with breaks from custom 1`] = `""`; + +exports[`Should render categorical legend with breaks from default 1`] = `
- 0_format + US_format @@ -43,14 +46,43 @@ exports[`Should render categorical legend 1`] = ` - 10_format + CN_format + + + + + + + + + + + + + Other + @@ -90,9 +122,9 @@ exports[`Should render categorical legend 1`] = `
`; -exports[`Should render ranged legend 1`] = ` +exports[`Should render ordinal legend 1`] = ` `; + +exports[`Should render ordinal legend with breaks 1`] = ` +
+ + + + + + + 0_format + + + + + + + + + + + + 10_format + + + + + + + + + + + + + + + foobar_label + + + + + + +
+`; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js index 8f8ad4d24e715..a46492b6034a7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/components/categorical_legend.js @@ -9,24 +9,13 @@ import _ from 'lodash'; const EMPTY_VALUE = ''; export class CategoricalLegend extends React.Component { - constructor() { - super(); - this._isMounted = false; - this.state = { - label: EMPTY_VALUE, - isPointsOnly: null, - isLinesOnly: null, - }; - } + state = { + label: EMPTY_VALUE, + }; - async _loadParams() { - const label = await this.props.style.getField().getLabel(); - const isLinesOnly = await this.props.loadIsLinesOnly(); - const isPointsOnly = await this.props.loadIsPointsOnly(); - const newState = { label, isLinesOnly, isPointsOnly }; - if (this._isMounted && !_.isEqual(this.state, newState)) { - this.setState(newState); - } + componentDidMount() { + this._isMounted = true; + this._loadParams(); } componentDidUpdate() { @@ -37,9 +26,12 @@ export class CategoricalLegend extends React.Component { this._isMounted = false; } - componentDidMount() { - this._isMounted = true; - this._loadParams(); + async _loadParams() { + const label = await this.props.style.getField().getLabel(); + const newState = { label }; + if (this._isMounted && !_.isEqual(this.state, newState)) { + this.setState(newState); + } } render() { @@ -48,8 +40,8 @@ export class CategoricalLegend extends React.Component { } return this.props.style.renderBreakedLegend({ fieldLabel: this.state.label, - isLinesOnly: this.state.isLinesOnly, - isPointsOnly: this.state.isPointsOnly, + isLinesOnly: this.props.isLinesOnly, + isPointsOnly: this.props.isPointsOnly, symbolId: this.props.symbolId, }); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 200df9e5cc33d..42e88220bd1d9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -7,12 +7,26 @@ import { DynamicStyleProperty } from './dynamic_style_property'; import _ from 'lodash'; import { getComputedFieldName } from '../style_util'; -import { getColorRampStops } from '../../color_utils'; +import { getOrdinalColorRampStops, getColorPalette } from '../../color_utils'; import { ColorGradient } from '../../components/color_gradient'; import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiToolTip, + EuiTextColor, +} from '@elastic/eui'; import { VectorIcon } from '../components/legend/vector_icon'; import { VECTOR_STYLES } from '../vector_style_defaults'; +import { COLOR_MAP_TYPE } from '../../../../../common/constants'; +import { + isCategoricalStopsInvalid, + getOtherCategoryLabel, +} from '../components/color/color_stops_utils'; + +const EMPTY_STOPS = { stops: [], defaultColor: null }; export class DynamicColorProperty extends DynamicStyleProperty { syncCircleColorWithMb(mbLayerId, mbMap, alpha) { @@ -55,7 +69,22 @@ export class DynamicColorProperty extends DynamicStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); } - isCustomColorRamp() { + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); + } + + isOrdinal() { + return ( + typeof this._options.type === 'undefined' || this._options.type === COLOR_MAP_TYPE.ORDINAL + ); + } + + isCategorical() { + return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; + } + + isCustomOrdinalColorRamp() { return this._options.useCustomColorRamp; } @@ -63,16 +92,16 @@ export class DynamicColorProperty extends DynamicStyleProperty { return true; } - isScaled() { - return !this.isCustomColorRamp(); + isOrdinalScaled() { + return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); } - isRanged() { - return !this.isCustomColorRamp(); + isOrdinalRanged() { + return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); } - hasBreaks() { - return this.isCustomColorRamp(); + hasOrdinalBreaks() { + return (this.isOrdinal() && this.isCustomOrdinalColorRamp()) || this.isCategorical(); } _getMbColor() { @@ -82,6 +111,15 @@ export class DynamicColorProperty extends DynamicStyleProperty { return null; } + const targetName = getComputedFieldName(this._styleName, this._options.field.name); + if (this.isCategorical()) { + return this._getMbDataDrivenCategoricalColor({ targetName }); + } else { + return this._getMbDataDrivenOrdinalColor({ targetName }); + } + } + + _getMbDataDrivenOrdinalColor({ targetName }) { if ( this._options.useCustomColorRamp && (!this._options.customColorRamp || !this._options.customColorRamp.length) @@ -89,15 +127,12 @@ export class DynamicColorProperty extends DynamicStyleProperty { return null; } - return this._getMBDataDrivenColor({ - targetName: getComputedFieldName(this._styleName, this._options.field.name), - colorStops: this._getMBColorStops(), - isSteps: this._options.useCustomColorRamp, - }); - } + const colorStops = this._getMbOrdinalColorStops(); + if (!colorStops) { + return null; + } - _getMBDataDrivenColor({ targetName, colorStops, isSteps }) { - if (isSteps) { + if (this._options.useCustomColorRamp) { const firstStopValue = colorStops[0]; const lessThenFirstStopValue = firstStopValue - 1; return [ @@ -107,7 +142,6 @@ export class DynamicColorProperty extends DynamicStyleProperty { ...colorStops, ]; } - return [ 'interpolate', ['linear'], @@ -118,14 +152,92 @@ export class DynamicColorProperty extends DynamicStyleProperty { ]; } - _getMBColorStops() { + _getColorPaletteStops() { + if (this._options.useCustomColorPalette && this._options.customColorPalette) { + if (isCategoricalStopsInvalid(this._options.customColorPalette)) { + return EMPTY_STOPS; + } + + const stops = []; + for (let i = 1; i < this._options.customColorPalette.length; i++) { + const config = this._options.customColorPalette[i]; + stops.push({ + stop: config.stop, + color: config.color, + }); + } + + return { + defaultColor: this._options.customColorPalette[0].color, + stops, + }; + } + + const fieldMeta = this.getFieldMeta(); + if (!fieldMeta || !fieldMeta.categories) { + return EMPTY_STOPS; + } + + const colors = getColorPalette(this._options.colorCategory); + if (!colors) { + return EMPTY_STOPS; + } + + const maxLength = Math.min(colors.length, fieldMeta.categories.length + 1); + const stops = []; + + for (let i = 0; i < maxLength - 1; i++) { + stops.push({ + stop: fieldMeta.categories[i].key, + color: colors[i], + }); + } + return { + stops, + defaultColor: colors[maxLength - 1], + }; + } + + _getMbDataDrivenCategoricalColor() { + if ( + this._options.useCustomColorPalette && + (!this._options.customColorPalette || !this._options.customColorPalette.length) + ) { + return null; + } + + const { stops, defaultColor } = this._getColorPaletteStops(); + if (stops.length < 1) { + //occurs when no data + return null; + } + + if (!defaultColor) { + return null; + } + + const mbStops = []; + for (let i = 0; i < stops.length; i++) { + const stop = stops[i]; + const branch = `${stop.stop}`; + if (typeof branch === 'string') { + mbStops.push(branch); + mbStops.push(stop.color); + } + } + + mbStops.push(defaultColor); //last color is default color + return ['match', ['get', this._options.field.name], ...mbStops]; + } + + _getMbOrdinalColorStops() { if (this._options.useCustomColorRamp) { return this._options.customColorRamp.reduce((accumulatedStops, nextStop) => { return [...accumulatedStops, nextStop.stop, nextStop.color]; }, []); + } else { + return getOrdinalColorRampStops(this._options.color); } - - return getColorRampStops(this._options.color); } renderRangeLegendHeader() { @@ -146,44 +258,59 @@ export class DynamicColorProperty extends DynamicStyleProperty { ); } - const loadIsLinesOnly = () => { - return isLinesOnly; - }; - - const loadIsPointsOnly = () => { - return isPointsOnly; - }; - - const getColorForProperty = (styleProperty, isLinesOnly) => { - if (isLinesOnly) { - return color; - } - - return this.getStyleName() === styleProperty ? color : 'none'; - }; - + const fillColor = this.getStyleName() === VECTOR_STYLES.FILL_COLOR ? color : 'none'; return ( ); } + _getColorRampStops() { + return this._options.useCustomColorRamp && this._options.customColorRamp + ? this._options.customColorRamp + : []; + } + + _getColorStops() { + if (this.isOrdinal()) { + return { + stops: this._getColorRampStops(), + defaultColor: null, + }; + } else if (this.isCategorical()) { + return this._getColorPaletteStops(); + } else { + return EMPTY_STOPS; + } + } + _renderColorbreaks({ isLinesOnly, isPointsOnly, symbolId }) { - if (!this._options.customColorRamp) { - return null; + const { stops, defaultColor } = this._getColorStops(); + const colorAndLabels = stops.map(config => { + return { + label: this.formatField(config.stop), + color: config.color, + }; + }); + + if (defaultColor) { + colorAndLabels.push({ + label: {getOtherCategoryLabel()}, + color: defaultColor, + }); } - return this._options.customColorRamp.map((config, index) => { - const value = this.formatField(config.stop); + return colorAndLabels.map((config, index) => { return ( - {value} + {config.label} {this._renderStopIcon(config.color, isLinesOnly, isPointsOnly, symbolId)} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index dbf704c9cbe4c..83cd101d30212 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -4,18 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line no-unused-vars +jest.mock('../components/vector_style_editor', () => ({ + VectorStyleEditor: () => { + return
mockVectorStyleEditor
; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; import { VECTOR_STYLES } from '../vector_style_defaults'; import { DynamicColorProperty } from './dynamic_color_property'; +import { COLOR_MAP_TYPE } from '../../../../../common/constants'; const mockField = { async getLabel() { return 'foobar_label'; }, - getName() { return 'foobar'; }, @@ -24,43 +29,61 @@ const mockField = { }, }; -test('Should render ranged legend', async () => { - const colorStyle = new DynamicColorProperty( - { - color: 'Blues', - }, +const getOrdinalFieldMeta = () => { + return { min: 0, max: 100 }; +}; + +const getCategoricalFieldMeta = () => { + return { + categories: [ + { + key: 'US', + count: 10, + }, + { + key: 'CN', + count: 8, + }, + ], + }; +}; +const makeProperty = (options, getFieldMeta) => { + return new DynamicColorProperty( + options, VECTOR_STYLES.LINE_COLOR, mockField, - () => { - return { min: 0, max: 100 }; - }, + getFieldMeta, () => { return x => x + '_format'; } ); +}; - const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; +const defaultLegendParams = { + isPointsOnly: true, + isLinesOnly: false, +}; + +test('Should render ordinal legend', async () => { + const colorStyle = makeProperty( + { + color: 'Blues', + type: undefined, }, - }); + getOrdinalFieldMeta + ); - const component = shallow(legendRow); + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); + const component = shallow(legendRow); expect(component).toMatchSnapshot(); }); -test('Should render categorical legend', async () => { - const colorStyle = new DynamicColorProperty( +test('Should render ordinal legend with breaks', async () => { + const colorStyle = makeProperty( { + type: COLOR_MAP_TYPE.ORDINAL, useCustomColorRamp: true, customColorRamp: [ { @@ -73,24 +96,32 @@ test('Should render categorical legend', async () => { }, ], }, - VECTOR_STYLES.LINE_COLOR, - mockField, - () => { - return { min: 0, max: 100 }; - }, - () => { - return x => x + '_format'; - } + getOrdinalFieldMeta ); - const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); + + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise(resolve => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); +}); + +test('Should render categorical legend with breaks from default', async () => { + const colorStyle = makeProperty( + { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: false, + colorCategory: 'palette_0', }, - }); + getCategoricalFieldMeta + ); + + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); const component = shallow(legendRow); @@ -101,3 +132,92 @@ test('Should render categorical legend', async () => { expect(component).toMatchSnapshot(); }); + +test('Should render categorical legend with breaks from custom', async () => { + const colorStyle = makeProperty( + { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: true, + customColorPalette: [ + { + stop: null, //should include the default stop + color: '#FFFF00', + }, + { + stop: 'US_STOP', + color: '#FF0000', + }, + { + stop: 'CN_STOP', + color: '#00FF00', + }, + ], + }, + getCategoricalFieldMeta + ); + + const legendRow = colorStyle.renderLegendDetailRow(defaultLegendParams); + + const component = shallow(legendRow); + + expect(component).toMatchSnapshot(); +}); + +function makeFeatures(foobarPropValues) { + return foobarPropValues.map(value => { + return { + type: 'Feature', + properties: { + foobar: value, + }, + }; + }); +} + +test('Should pluck the categorical style-meta', async () => { + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.CATEGORICAL, + colorCategory: 'palette_0', + getCategoricalFieldMeta, + }); + + const features = makeFeatures(['CN', 'CN', 'US', 'CN', 'US', 'IN']); + const meta = colorStyle.pluckStyleMetaFromFeatures(features); + + expect(meta).toEqual({ + categories: [ + { key: 'CN', count: 3 }, + { key: 'US', count: 2 }, + { key: 'IN', count: 1 }, + ], + }); +}); + +test('Should pluck the categorical style-meta from fieldmeta', async () => { + const colorStyle = makeProperty({ + type: COLOR_MAP_TYPE.CATEGORICAL, + colorCategory: 'palette_0', + getCategoricalFieldMeta, + }); + + const meta = colorStyle.pluckStyleMetaFromFieldMetaData({ + foobar: { + buckets: [ + { + key: 'CN', + doc_count: 3, + }, + { key: 'US', doc_count: 2 }, + { key: 'IN', doc_count: 1 }, + ], + }, + }); + + expect(meta).toEqual({ + categories: [ + { key: 'CN', count: 3 }, + { key: 'US', count: 2 }, + { key: 'IN', count: 1 }, + ], + }); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js index 5b6f494600c2a..1d2457142c082 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js @@ -26,7 +26,7 @@ export class DynamicOrientationProperty extends DynamicStyleProperty { return false; } - isScaled() { + isOrdinalScaled() { return false; } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index f2e5672226814..5a4da1a80c918 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -5,7 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; -import { getComputedFieldName } from '../style_util'; + import { HALF_LARGE_MAKI_ICON_SIZE, LARGE_MAKI_ICON_SIZE, @@ -63,7 +63,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncHaloWidthWithMb(mbLayerId, mbMap) { - const haloWidth = this._getMbSize(); + const haloWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', haloWidth); } @@ -76,7 +76,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { mbMap.setLayoutProperty(symbolLayerId, 'icon-image', `${symbolId}-${iconPixels}`); const halfIconPixels = iconPixels / 2; - const targetName = getComputedFieldName(VECTOR_STYLES.ICON_SIZE, this._options.field.name); + const targetName = this.getComputedFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ 'interpolate', @@ -94,29 +94,29 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncCircleStrokeWidthWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', lineWidth); } syncCircleRadiusWithMb(mbLayerId, mbMap) { - const circleRadius = this._getMbSize(); + const circleRadius = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'circle-radius', circleRadius); } syncLineWidthWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'line-width', lineWidth); } syncLabelSizeWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setLayoutProperty(mbLayerId, 'text-size', lineWidth); } - _getMbSize() { + getMbSizeExpression() { if (this._isSizeDynamicConfigComplete(this._options)) { return this._getMbDataDrivenSize({ - targetName: getComputedFieldName(this._styleName, this._options.field.name), + targetName: this.getComputedFieldName(), minSize: this._options.minSize, maxSize: this._options.maxSize, }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index bac3c96581967..98e87b0305b44 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -7,11 +7,12 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; -import { STYLE_TYPE } from '../../../../../common/constants'; -import { scaleValue } from '../style_util'; +import { COLOR_PALETTE_MAX_SIZE, STYLE_TYPE } from '../../../../../common/constants'; +import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; +import { OrdinalFieldMetaOptionsPopover } from '../components/ordinal_field_meta_options_popover'; export class DynamicStyleProperty extends AbstractStyleProperty { static type = STYLE_TYPE.DYNAMIC; @@ -31,6 +32,13 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return this._field; } + getComputedFieldName() { + if (!this.isComplete()) { + return null; + } + return getComputedFieldName(this._styleName, this.getField().getName()); + } + isDynamic() { return true; } @@ -39,11 +47,15 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return true; } - hasBreaks() { + isCategorical() { return false; } - isRanged() { + hasOrdinalBreaks() { + return false; + } + + isOrdinalRanged() { return true; } @@ -61,21 +73,33 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } supportsFieldMeta() { - return this.isComplete() && this.isScaled() && this._field.supportsFieldMeta(); + if (this.isOrdinal()) { + return this.isComplete() && this.isOrdinalScaled() && this._field.supportsFieldMeta(); + } else if (this.isCategorical()) { + return this.isComplete() && this._field.supportsFieldMeta(); + } else { + return false; + } } async getFieldMetaRequest() { - const fieldMetaOptions = this.getFieldMetaOptions(); - return this._field.getFieldMetaRequest({ - sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA), - }); + if (this.isOrdinal()) { + const fieldMetaOptions = this.getFieldMetaOptions(); + return this._field.getOrdinalFieldMetaRequest({ + sigma: _.get(fieldMetaOptions, 'sigma', DEFAULT_SIGMA), + }); + } else if (this.isCategorical()) { + return this._field.getCategoricalFieldMetaRequest(); + } else { + return null; + } } supportsFeatureState() { return true; } - isScaled() { + isOrdinalScaled() { return true; } @@ -83,11 +107,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return _.get(this.getOptions(), 'fieldMetaOptions', {}); } - pluckStyleMetaFromFeatures(features) { - if (!this.isOrdinal()) { - return null; - } - + _pluckOrdinalStyleMetaFromFeatures(features) { const name = this.getField().getName(); let min = Infinity; let max = -Infinity; @@ -109,11 +129,47 @@ export class DynamicStyleProperty extends AbstractStyleProperty { }; } - pluckStyleMetaFromFieldMetaData(fieldMetaData) { - if (!this.isOrdinal()) { + _pluckCategoricalStyleMetaFromFeatures(features) { + const fieldName = this.getField().getName(); + const counts = new Map(); + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const term = feature.properties[fieldName]; + //properties object may be sparse, so need to check if the field is effectively present + if (typeof term !== undefined) { + if (counts.has(term)) { + counts.set(term, counts.get(term) + 1); + } else { + counts.set(term, 1); + } + } + } + + const ordered = []; + for (const [key, value] of counts) { + ordered.push({ key, count: value }); + } + + ordered.sort((a, b) => { + return b.count - a.count; + }); + const truncated = ordered.slice(0, COLOR_PALETTE_MAX_SIZE); + return { + categories: truncated, + }; + } + + pluckStyleMetaFromFeatures(features) { + if (this.isOrdinal()) { + return this._pluckOrdinalStyleMetaFromFeatures(features); + } else if (this.isCategorical()) { + return this._pluckCategoricalStyleMetaFromFeatures(features); + } else { return null; } + } + _pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData) { const realFieldName = this._field.getESDocFieldName ? this._field.getESDocFieldName() : this._field.getName(); @@ -136,6 +192,33 @@ export class DynamicStyleProperty extends AbstractStyleProperty { }; } + _pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData) { + const name = this.getField().getName(); + if (!fieldMetaData[name] || !fieldMetaData[name].buckets) { + return null; + } + + const ordered = fieldMetaData[name].buckets.map(bucket => { + return { + key: bucket.key, + count: bucket.doc_count, + }; + }); + return { + categories: ordered, + }; + } + + pluckStyleMetaFromFieldMetaData(fieldMetaData) { + if (this.isOrdinal()) { + return this._pluckOrdinalStyleMetaFromFieldMetaData(fieldMetaData); + } else if (this.isCategorical()) { + return this._pluckCategoricalStyleMetaFromFieldMetaData(fieldMetaData); + } else { + return null; + } + } + formatField(value) { if (this.getField()) { const fieldName = this.getField().getName(); @@ -152,7 +235,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } const valueAsFloat = parseFloat(value); - if (this.isScaled()) { + if (this.isOrdinalScaled()) { return scaleValue(valueAsFloat, this.getFieldMeta()); } if (isNaN(valueAsFloat)) { @@ -165,12 +248,12 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return null; } - _renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + _renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }) { return ( ); @@ -180,13 +263,29 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return ; } - renderLegendDetailRow({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { - if (this.isRanged()) { - return this._renderRangeLegend(); - } else if (this.hasBreaks()) { - return this._renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }); + renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }) { + if (this.isOrdinal()) { + if (this.isOrdinalRanged()) { + return this._renderRangeLegend(); + } else if (this.hasOrdinalBreaks()) { + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); + } else { + return null; + } + } else if (this.isCategorical()) { + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); } else { return null; } } + + renderFieldMetaPopover(onFieldMetaOptionsChange) { + if (!this.isOrdinal() || !this.supportsFieldMeta()) { + return null; + } + + return ( + + ); + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js index fbc4c3af78f98..6a40a80a1a7a6 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js @@ -29,7 +29,7 @@ export class DynamicTextProperty extends DynamicStyleProperty { return false; } - isScaled() { + isOrdinalScaled() { return false; } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js new file mode 100644 index 0000000000000..e08c2875c310e --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { AbstractStyleProperty } from './style_property'; +import { DEFAULT_LABEL_SIZE, LABEL_BORDER_SIZES } from '../vector_style_defaults'; + +const SMALL_SIZE = 1 / 16; +const MEDIUM_SIZE = 1 / 8; +const LARGE_SIZE = 1 / 5; // halo of 1/4 is just a square. Use smaller ratio to preserve contour on letters + +function getWidthRatio(size) { + switch (size) { + case LABEL_BORDER_SIZES.LARGE: + return LARGE_SIZE; + case LABEL_BORDER_SIZES.MEDIUM: + return MEDIUM_SIZE; + default: + return SMALL_SIZE; + } +} + +export class LabelBorderSizeProperty extends AbstractStyleProperty { + constructor(options, styleName, labelSizeProperty) { + super(options, styleName); + this._labelSizeProperty = labelSizeProperty; + } + + syncLabelBorderSizeWithMb(mbLayerId, mbMap) { + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); + } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + } else { + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + } + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js index 658eb6a164556..ebe2a322711fc 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js @@ -39,4 +39,8 @@ export class StaticColorProperty extends StaticStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-color', this._options.color); mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); } + + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', this._options.color); + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.js index 52e1a46a18e94..c49fe46664025 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/style_property.js @@ -45,6 +45,10 @@ export class AbstractStyleProperty { return null; } + renderFieldMetaPopover() { + return null; + } + getDisplayStyleName() { return getVectorStyleLabel(this.getStyleName()); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index b8fc428a62a52..7bd60ea6502bc 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js @@ -17,10 +17,6 @@ export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatu return supportedFeatures[0] === featureType; } - if (!hasFeatureType) { - return false; - } - const featureTypes = Object.keys(hasFeatureType); return featureTypes.reduce((isOnlyTargetFeatureType, featureTypeKey) => { const hasFeature = hasFeatureType[featureTypeKey]; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index ea80b188e1646..30d1c5726ba48 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -38,6 +38,7 @@ import { StaticOrientationProperty } from './properties/static_orientation_prope import { DynamicOrientationProperty } from './properties/dynamic_orientation_property'; import { StaticTextProperty } from './properties/static_text_property'; import { DynamicTextProperty } from './properties/dynamic_text_property'; +import { LabelBorderSizeProperty } from './properties/label_border_size_property'; import { extractColorFromStyleProperty } from './components/legend/extract_color_from_style_property'; const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT]; @@ -100,6 +101,15 @@ export class VectorStyle extends AbstractStyle { this._descriptor.properties[VECTOR_STYLES.LABEL_COLOR], VECTOR_STYLES.LABEL_COLOR ); + this._labelBorderColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR], + VECTOR_STYLES.LABEL_BORDER_COLOR + ); + this._labelBorderSizeStyleProperty = new LabelBorderSizeProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_SIZE].options, + VECTOR_STYLES.LABEL_BORDER_SIZE, + this._labelSizeStyleProperty + ); } _getAllStyleProperties() { @@ -112,6 +122,8 @@ export class VectorStyle extends AbstractStyle { this._labelStyleProperty, this._labelSizeStyleProperty, this._labelColorStyleProperty, + this._labelBorderColorStyleProperty, + this._labelBorderSizeStyleProperty, ]; } @@ -143,8 +155,8 @@ export class VectorStyle extends AbstractStyle { styleProperties={styleProperties} symbolDescriptor={this._descriptor.properties[VECTOR_STYLES.SYMBOL]} layer={layer} - loadIsPointsOnly={this._getIsPointsOnly} - loadIsLinesOnly={this._getIsLinesOnly} + isPointsOnly={this._getIsPointsOnly()} + isLinesOnly={this._getIsLinesOnly()} onIsTimeAwareChange={onIsTimeAwareChange} isTimeAware={this.isTimeAware()} showIsTimeAware={propertiesWithFieldMeta.length > 0} @@ -218,43 +230,57 @@ export class VectorStyle extends AbstractStyle { async pluckStyleMetaFromSourceDataRequest(sourceDataRequest) { const features = _.get(sourceDataRequest.getData(), 'features', []); - if (features.length === 0) { - return {}; - } - - const dynamicProperties = this.getDynamicPropertiesArray(); const supportedFeatures = await this._source.getSupportedShapeTypes(); - const isSingleFeatureType = supportedFeatures.length === 1; - if (dynamicProperties.length === 0 && isSingleFeatureType) { - // no meta data to pull from source data request. - return {}; - } - - let hasPoints = false; - let hasLines = false; - let hasPolygons = false; - for (let i = 0; i < features.length; i++) { - const feature = features[i]; - if (!hasPoints && POINTS.includes(feature.geometry.type)) { - hasPoints = true; - } - if (!hasLines && LINES.includes(feature.geometry.type)) { - hasLines = true; - } - if (!hasPolygons && POLYGONS.includes(feature.geometry.type)) { - hasPolygons = true; + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: false, + [VECTOR_SHAPE_TYPES.LINE]: false, + [VECTOR_SHAPE_TYPES.POLYGON]: false, + }; + if (supportedFeatures.length > 1) { + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + if (!hasFeatureType[VECTOR_SHAPE_TYPES.POINT] && POINTS.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.POINT] = true; + } + if (!hasFeatureType[VECTOR_SHAPE_TYPES.LINE] && LINES.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.LINE] = true; + } + if ( + !hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] && + POLYGONS.includes(feature.geometry.type) + ) { + hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] = true; + } } } const featuresMeta = { - hasFeatureType: { - [VECTOR_SHAPE_TYPES.POINT]: hasPoints, - [VECTOR_SHAPE_TYPES.LINE]: hasLines, - [VECTOR_SHAPE_TYPES.POLYGON]: hasPolygons, + geometryTypes: { + isPointsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POINT, + supportedFeatures, + hasFeatureType + ), + isLinesOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.LINE, + supportedFeatures, + hasFeatureType + ), + isPolygonsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POLYGON, + supportedFeatures, + hasFeatureType + ), }, }; + const dynamicProperties = this.getDynamicPropertiesArray(); + if (dynamicProperties.length === 0 || features.length === 0) { + // no additional meta data to pull from source data request. + return featuresMeta; + } + dynamicProperties.forEach(dynamicProperty => { const styleMeta = dynamicProperty.pluckStyleMetaFromFeatures(features); if (styleMeta) { @@ -291,24 +317,16 @@ export class VectorStyle extends AbstractStyle { ); } - _isOnlySingleFeatureType = async featureType => { - return isOnlySingleFeatureType( - featureType, - await this._source.getSupportedShapeTypes(), - this._getStyleMeta().hasFeatureType - ); + _getIsPointsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPointsOnly', false); }; - _getIsPointsOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT); + _getIsLinesOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isLinesOnly', false); }; - _getIsLinesOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE); - }; - - _getIsPolygonsOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POLYGON); + _getIsPolygonsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPolygonsOnly', false); }; _getDynamicPropertyByFieldName(fieldName) { @@ -393,50 +411,44 @@ export class VectorStyle extends AbstractStyle { : this._descriptor.properties.symbol.options.symbolId; } - _getColorForProperty = (styleProperty, isLinesOnly) => { - const styles = this.getRawProperties(); - if (isLinesOnly) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'grey'); - } - - if (styleProperty === VECTOR_STYLES.LINE_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'none'); - } else if (styleProperty === VECTOR_STYLES.FILL_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.FILL_COLOR], 'grey'); - } else { - //unexpected - console.error('Cannot return color for properties other then line or fill color'); - } - }; - getIcon = () => { - const symbolId = this._getSymbolId(); + const isLinesOnly = this._getIsLinesOnly(); + const strokeColor = isLinesOnly + ? extractColorFromStyleProperty(this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], 'grey') + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], + 'none' + ); + const fillColor = isLinesOnly + ? null + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.FILL_COLOR], + 'grey' + ); return ( ); }; - _getLegendDetailStyleProperties = async () => { - const isLinesOnly = await this._getIsLinesOnly(); - const isPolygonsOnly = await this._getIsPolygonsOnly(); - + _getLegendDetailStyleProperties = () => { return this.getDynamicPropertiesArray().filter(styleProperty => { const styleName = styleProperty.getStyleName(); if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { return false; } - if (isLinesOnly) { + if (this._getIsLinesOnly()) { return LINE_STYLES.includes(styleName); } - if (isPolygonsOnly) { + if (this._getIsPolygonsOnly()) { return POLYGON_STYLES.includes(styleName); } @@ -445,16 +457,15 @@ export class VectorStyle extends AbstractStyle { }; async hasLegendDetails() { - const styles = await this._getLegendDetailStyleProperties(); - return styles.length > 0; + return this._getLegendDetailStyleProperties().length > 0; } renderLegendDetails() { return ( ); @@ -538,6 +549,8 @@ export class VectorStyle extends AbstractStyle { this._labelStyleProperty.syncTextFieldWithMb(textLayerId, mbMap); this._labelColorStyleProperty.syncLabelColorWithMb(textLayerId, mbMap, alpha); this._labelSizeStyleProperty.syncLabelSizeWithMb(textLayerId, mbMap); + this._labelBorderSizeStyleProperty.syncLabelBorderSizeWithMb(textLayerId, mbMap); + this._labelBorderColorStyleProperty.syncLabelBorderColorWithMb(textLayerId, mbMap); } setMBSymbolPropertiesForPoints({ mbMap, symbolLayerId, alpha }) { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index aa0badd5583d5..c250d83720580 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -102,6 +102,17 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { }, type: 'STATIC', }, + labelBorderColor: { + options: { + color: '#FFFFFF', + }, + type: 'STATIC', + }, + labelBorderSize: { + options: { + size: 'SMALL', + }, + }, labelColor: { options: { color: '#000000', @@ -159,11 +170,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { const vectorStyle = new VectorStyle({}, new MockSource()); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: false, - POINT: true, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); it('Should identify when feature collection only contains lines', async () => { @@ -189,11 +198,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { const vectorStyle = new VectorStyle({}, new MockSource()); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: true, - POINT: false, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(false); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(true); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); }); @@ -241,11 +248,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { ); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: false, - POINT: true, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); it('Should extract scaled field range', async () => { @@ -275,88 +280,3 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { }); }); }); - -describe('checkIfOnlyFeatureType', () => { - describe('source supports single feature type', () => { - it('isPointsOnly should be true when source feature type only supports points', async () => { - const vectorStyle = new VectorStyle( - {}, - new MockSource({ - supportedShapeTypes: [VECTOR_SHAPE_TYPES.POINT], - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(true); - }); - - it('isLineOnly should be false when source feature type only supports points', async () => { - const vectorStyle = new VectorStyle( - {}, - new MockSource({ - supportedShapeTypes: [VECTOR_SHAPE_TYPES.POINT], - }) - ); - const isLineOnly = await vectorStyle._getIsLinesOnly(); - expect(isLineOnly).toBe(false); - }); - }); - - describe('source supports multiple feature types', () => { - it('isPointsOnly should be true when data contains just points', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: true, - LINE: false, - POLYGON: false, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(true); - }); - - it('isPointsOnly should be false when data contains just lines', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: false, - LINE: true, - POLYGON: false, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(false); - }); - - it('isPointsOnly should be false when data contains points, lines, and polygons', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: true, - LINE: true, - POLYGON: true, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(false); - }); - }); -}); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js index 4bae90c3165f2..54af55b61ab2e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js @@ -6,7 +6,12 @@ import { VectorStyle } from './vector_style'; import { SYMBOLIZE_AS_CIRCLE, DEFAULT_ICON_SIZE } from './vector_constants'; -import { COLOR_GRADIENTS, DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../color_utils'; +import { + COLOR_GRADIENTS, + COLOR_PALETTES, + DEFAULT_FILL_COLORS, + DEFAULT_LINE_COLORS, +} from '../color_utils'; import chrome from 'ui/chrome'; const DEFAULT_ICON = 'airfield'; @@ -16,6 +21,14 @@ export const MAX_SIZE = 64; export const DEFAULT_MIN_SIZE = 4; export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; +export const DEFAULT_LABEL_SIZE = 14; + +export const LABEL_BORDER_SIZES = { + NONE: 'NONE', + SMALL: 'SMALL', + MEDIUM: 'MEDIUM', + LARGE: 'LARGE', +}; export const VECTOR_STYLES = { SYMBOL: 'symbol', @@ -27,6 +40,8 @@ export const VECTOR_STYLES = { LABEL_TEXT: 'labelText', LABEL_COLOR: 'labelColor', LABEL_SIZE: 'labelSize', + LABEL_BORDER_COLOR: 'labelBorderColor', + LABEL_BORDER_SIZE: 'labelBorderSize', }; export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; @@ -45,6 +60,11 @@ export function getDefaultProperties(mapColors = []) { symbolId: DEFAULT_ICON, }, }, + [VECTOR_STYLES.LABEL_BORDER_SIZE]: { + options: { + size: LABEL_BORDER_SIZES.SMALL, + }, + }, }; } @@ -103,7 +123,13 @@ export function getDefaultStaticProperties(mapColors = []) { [VECTOR_STYLES.LABEL_SIZE]: { type: VectorStyle.STYLE_TYPE.STATIC, options: { - size: 14, + size: DEFAULT_LABEL_SIZE, + }, + }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: isDarkMode ? '#000000' : '#FFFFFF', }, }, }; @@ -115,6 +141,7 @@ export function getDefaultDynamicProperties() { type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { color: COLOR_GRADIENTS[0].value, + colorCategory: COLOR_PALETTES[0].value, field: undefined, fieldMetaOptions: { isEnabled: true, @@ -125,7 +152,7 @@ export function getDefaultDynamicProperties() { [VECTOR_STYLES.LINE_COLOR]: { type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { - color: COLOR_GRADIENTS[0].value, + color: undefined, field: undefined, fieldMetaOptions: { isEnabled: true, @@ -158,7 +185,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.ICON_ORIENTATION]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, fieldMetaOptions: { @@ -168,15 +195,16 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.LABEL_TEXT]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, }, }, [VECTOR_STYLES.LABEL_COLOR]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { color: COLOR_GRADIENTS[0].value, + colorCategory: COLOR_PALETTES[0].value, field: undefined, fieldMetaOptions: { isEnabled: true, @@ -185,7 +213,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.LABEL_SIZE]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { minSize: DEFAULT_MIN_SIZE, maxSize: DEFAULT_MAX_SIZE, @@ -196,5 +224,17 @@ export function getDefaultDynamicProperties() { }, }, }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + colorCategory: COLOR_PALETTES[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, }; } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index dd9a1b7a14c10..96223aa536170 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -213,6 +213,10 @@ export class VectorLayer extends AbstractLayer { return [...(await this.getDateFields()), ...(await this.getNumberFields())]; } + async getCategoricalFields() { + return await this._source.getCategoricalFields(); + } + async getFields() { const sourceFields = await this._source.getFields(); return [...sourceFields, ...this._getJoinFields()]; diff --git a/x-pack/legacy/plugins/maps/server/sample_data/ecommerce_saved_objects.js b/x-pack/legacy/plugins/maps/server/sample_data/ecommerce_saved_objects.js index d5bcfa7cd1512..a3dbf8b1438fa 100644 --- a/x-pack/legacy/plugins/maps/server/sample_data/ecommerce_saved_objects.js +++ b/x-pack/legacy/plugins/maps/server/sample_data/ecommerce_saved_objects.js @@ -39,7 +39,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'count of kibana_sample_data_ecommerce:geoip.country_iso_code', name: '__kbnjoin__count_groupby_kibana_sample_data_ecommerce.geoip.country_iso_code', origin: 'join', }, @@ -104,7 +103,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'count of kibana_sample_data_ecommerce:geoip.region_name', name: '__kbnjoin__count_groupby_kibana_sample_data_ecommerce.geoip.region_name', origin: 'join', }, @@ -169,7 +167,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'count of kibana_sample_data_ecommerce:geoip.region_name', name: '__kbnjoin__count_groupby_kibana_sample_data_ecommerce.geoip.region_name', origin: 'join', }, @@ -234,7 +231,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'count of kibana_sample_data_ecommerce:geoip.region_name', name: '__kbnjoin__count_groupby_kibana_sample_data_ecommerce.geoip.region_name', origin: 'join', }, @@ -314,7 +310,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'taxful_total_price', name: 'taxful_total_price', origin: 'source', }, @@ -376,7 +371,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'Count', name: 'doc_count', origin: 'source', }, @@ -399,7 +393,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'sum of taxful_total_price', name: 'sum_of_taxful_total_price', origin: 'source', }, @@ -407,6 +400,31 @@ const layerList = [ maxSize: 20, }, }, + labelText: { + type: 'DYNAMIC', + options: { + field: { + name: 'sum_of_taxful_total_price', + origin: 'source', + }, + }, + }, + labelSize: { + type: 'DYNAMIC', + options: { + field: { + name: 'sum_of_taxful_total_price', + origin: 'source', + }, + minSize: 12, + maxSize: 24, + }, + }, + labelBorderSize: { + options: { + size: 'MEDIUM', + }, + }, }, }, type: 'VECTOR', diff --git a/x-pack/legacy/plugins/maps/server/sample_data/flights_saved_objects.js b/x-pack/legacy/plugins/maps/server/sample_data/flights_saved_objects.js index aa3d5488764ad..cff4ad8182a9d 100644 --- a/x-pack/legacy/plugins/maps/server/sample_data/flights_saved_objects.js +++ b/x-pack/legacy/plugins/maps/server/sample_data/flights_saved_objects.js @@ -54,7 +54,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'FlightTimeMin', name: 'FlightTimeMin', origin: 'source', }, @@ -77,7 +76,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'DistanceMiles', name: 'DistanceMiles', origin: 'source', }, @@ -122,7 +120,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'Count', name: 'doc_count', origin: 'source', }, @@ -145,7 +142,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'avg of FlightTimeMin', name: 'avg_of_FlightTimeMin', origin: 'source', }, @@ -190,7 +186,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'Count', name: 'doc_count', origin: 'source', }, @@ -213,7 +208,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'avg of FlightDelayMin', name: 'avg_of_FlightDelayMin', origin: 'source', }, diff --git a/x-pack/legacy/plugins/maps/server/sample_data/web_logs_saved_objects.js b/x-pack/legacy/plugins/maps/server/sample_data/web_logs_saved_objects.js index 74039b11db727..ec445567de21c 100644 --- a/x-pack/legacy/plugins/maps/server/sample_data/web_logs_saved_objects.js +++ b/x-pack/legacy/plugins/maps/server/sample_data/web_logs_saved_objects.js @@ -39,7 +39,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'count of kibana_sample_data_logs:geo.src', name: '__kbnjoin__count_groupby_kibana_sample_data_logs.geo.src', origin: 'join', }, @@ -135,7 +134,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'bytes', name: 'bytes', origin: 'source', }, @@ -179,7 +177,6 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'Count', name: 'doc_count', origin: 'source', }, @@ -202,14 +199,33 @@ const layerList = [ type: 'DYNAMIC', options: { field: { - label: 'sum of bytes', name: 'sum_of_bytes', origin: 'source', }, - minSize: 1, + minSize: 7, maxSize: 25, }, }, + labelText: { + type: 'DYNAMIC', + options: { + field: { + name: 'doc_count', + origin: 'source', + }, + }, + }, + labelSize: { + type: 'DYNAMIC', + options: { + field: { + name: 'doc_count', + origin: 'source', + }, + minSize: 12, + maxSize: 24, + }, + }, }, }, type: 'VECTOR', diff --git a/x-pack/legacy/plugins/maps/server/test_utils/index.js b/x-pack/legacy/plugins/maps/server/test_utils/index.js index 944d65a21aae2..f208917e20924 100644 --- a/x-pack/legacy/plugins/maps/server/test_utils/index.js +++ b/x-pack/legacy/plugins/maps/server/test_utils/index.js @@ -25,24 +25,3 @@ export const getMockCallWithInternal = (hits = defaultMockSavedObjects) => { export const getMockTaskFetch = (docs = defaultMockTaskDocs) => { return () => Promise.resolve({ docs }); }; - -export const getMockKbnServer = ( - mockCallWithInternal = getMockCallWithInternal(), - mockTaskFetch = getMockTaskFetch() -) => ({ - plugins: { - elasticsearch: { - getCluster: () => ({ - callWithInternalUser: mockCallWithInternal, - }), - }, - xpack_main: {}, - task_manager: { - registerTaskDefinitions: () => undefined, - schedule: () => Promise.resolve(), - fetch: mockTaskFetch, - }, - }, - config: () => ({ get: () => '' }), - log: () => undefined, -}); diff --git a/x-pack/legacy/plugins/ml/common/constants/new_job.ts b/x-pack/legacy/plugins/ml/common/constants/new_job.ts index ccd108cd2698f..3c98b372afdf7 100644 --- a/x-pack/legacy/plugins/ml/common/constants/new_job.ts +++ b/x-pack/legacy/plugins/ml/common/constants/new_job.ts @@ -27,6 +27,6 @@ export const DEFAULT_QUERY_DELAY = '60s'; export const SHARED_RESULTS_INDEX_NAME = 'shared'; export const NUMBER_OF_CATEGORY_EXAMPLES = 5; -export const CATEGORY_EXAMPLES_MULTIPLIER = 20; +export const CATEGORY_EXAMPLES_SAMPLE_SIZE = 1000; export const CATEGORY_EXAMPLES_WARNING_LIMIT = 0.75; -export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.2; +export const CATEGORY_EXAMPLES_ERROR_LIMIT = 0.02; diff --git a/x-pack/legacy/plugins/ml/common/types/fields.ts b/x-pack/legacy/plugins/ml/common/types/fields.ts index 9e1b992eec907..4860ab955d066 100644 --- a/x-pack/legacy/plugins/ml/common/types/fields.ts +++ b/x-pack/legacy/plugins/ml/common/types/fields.ts @@ -23,7 +23,7 @@ export interface Field { id: FieldId; name: string; type: ES_FIELD_TYPES; - aggregatable: boolean; + aggregatable?: boolean; aggIds?: AggId[]; aggs?: Aggregation[]; } diff --git a/x-pack/legacy/plugins/ml/common/types/jobs.ts b/x-pack/legacy/plugins/ml/common/types/jobs.ts index 07c2be3e7f0b4..47f34f6568eed 100644 --- a/x-pack/legacy/plugins/ml/common/types/jobs.ts +++ b/x-pack/legacy/plugins/ml/common/types/jobs.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Moment } from 'moment'; + // TS TODO: This is not yet a fully fledged representation of the job data structure, // but it fulfills some basic TypeScript related needs. export interface MlJob { @@ -63,6 +65,20 @@ export interface MlSummaryJob { export type MlSummaryJobs = MlSummaryJob[]; +export interface MlJobWithTimeRange extends MlJob { + groups: string[]; + timeRange: { + from: number; + to: number; + fromPx: number; + toPx: number; + fromMoment: Moment; + toMoment: Moment; + widthPx: number; + label: string; + }; +} + export function isMlJob(arg: any): arg is MlJob { return typeof arg.job_id === 'string'; } diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap index 29831190824ad..dba73c246c3d0 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap @@ -1,109 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AnnotationFlyout Initialization. 1`] = ` - -`; +exports[`AnnotationFlyout Initialization. 1`] = `""`; diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx index 7fa47f3518b81..d71a23f478282 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { injectObservablesAsProps } from '../../../util/observable_utils'; +import useObservable from 'react-use/lib/useObservable'; + import mockAnnotations from '../annotations_table/__mocks__/mock_annotations.json'; -import React, { ComponentType } from 'react'; +import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { Annotation } from '../../../../../common/types/annotations'; @@ -25,11 +26,14 @@ describe('AnnotationFlyout', () => { const annotation = mockAnnotations[1] as Annotation; annotation$.next(annotation); - // injectObservablesAsProps wraps the observable in a new component - const ObservableComponent = injectObservablesAsProps( - { annotation: annotation$ }, - (AnnotationFlyout as any) as ComponentType - ); + // useObservable wraps the observable in a new component + const ObservableComponent = (props: any) => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; + }; const wrapper = mountWithIntl(); const updateBtn = wrapper.find('EuiButton').first(); @@ -40,11 +44,14 @@ describe('AnnotationFlyout', () => { const annotation = mockAnnotations[2] as Annotation; annotation$.next(annotation); - // injectObservablesAsProps wraps the observable in a new component - const ObservableComponent = injectObservablesAsProps( - { annotation: annotation$ }, - (AnnotationFlyout as any) as ComponentType - ); + // useObservable wraps the observable in a new component + const ObservableComponent = (props: any) => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; + }; const wrapper = mountWithIntl(); const updateBtn = wrapper.find('EuiButton').first(); diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx index 84c16360795ea..6668518822710 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, ComponentType, Fragment, ReactNode } from 'react'; +import React, { Component, Fragment, FC, ReactNode } from 'react'; +import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; import { @@ -23,16 +24,16 @@ import { } from '@elastic/eui'; import { CommonProps } from '@elastic/eui'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; -import { InjectedIntlProps } from 'react-intl'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { toastNotifications } from 'ui/notify'; import { ANNOTATION_MAX_LENGTH_CHARS } from '../../../../../common/constants/annotations'; import { annotation$, - annotationsRefresh$, + annotationsRefreshed, AnnotationState, } from '../../../services/annotations_service'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; import { AnnotationDescriptionList } from '../annotation_description_list'; import { DeleteAnnotationModal } from '../delete_annotation_modal'; @@ -46,7 +47,7 @@ interface State { isDeleteModalVisible: boolean; } -class AnnotationFlyoutIntl extends Component { +class AnnotationFlyoutIntl extends Component { public state: State = { isDeleteModalVisible: false, }; @@ -73,7 +74,7 @@ class AnnotationFlyoutIntl extends Component { - const { annotation, intl } = this.props; + const { annotation } = this.props; if (annotation === null) { return; @@ -82,31 +83,30 @@ class AnnotationFlyoutIntl extends Component { @@ -116,7 +116,7 @@ class AnnotationFlyoutIntl extends Component { // Validates the entered text, returning an array of error messages // for display in the form. An empty array is returned if the text is valid. - const { annotation, intl } = this.props; + const { annotation } = this.props; const errors: string[] = []; if (annotation === null) { return errors; @@ -124,8 +124,7 @@ class AnnotationFlyoutIntl extends Component ANNOTATION_MAX_LENGTH_CHARS) { const charsOver = textLength - ANNOTATION_MAX_LENGTH_CHARS; errors.push( - intl.formatMessage( - { - id: 'xpack.ml.timeSeriesExplorer.annotationFlyout.maxLengthError', - defaultMessage: - '{charsOver, number} {charsOver, plural, one {character} other {characters}} above maximum length of {maxChars}', - }, - { + i18n.translate('xpack.ml.timeSeriesExplorer.annotationFlyout.maxLengthError', { + defaultMessage: + '{charsOver, number} {charsOver, plural, one {character} other {characters}} above maximum length of {maxChars}', + values: { maxChars: ANNOTATION_MAX_LENGTH_CHARS, charsOver, - } - ) + }, + }) ); } @@ -153,7 +149,7 @@ class AnnotationFlyoutIntl extends Component { - const { annotation, intl } = this.props; + const { annotation } = this.props; if (annotation === null) { return; @@ -164,27 +160,25 @@ class AnnotationFlyoutIntl extends Component { - annotationsRefresh$.next(true); + annotationsRefreshed(); if (typeof annotation._id === 'undefined') { toastNotifications.addSuccess( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage', defaultMessage: 'Added an annotation for job with ID {jobId}.', - }, - { jobId: annotation.job_id } + values: { jobId: annotation.job_id }, + } ) ); } else { toastNotifications.addSuccess( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage', defaultMessage: 'Updated annotation for job with ID {jobId}.', - }, - { jobId: annotation.job_id } + values: { jobId: annotation.job_id }, + } ) ); } @@ -192,26 +186,24 @@ class AnnotationFlyoutIntl extends Component { if (typeof annotation._id === 'undefined') { toastNotifications.addDanger( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage', defaultMessage: 'An error occurred creating the annotation for job with ID {jobId}: {error}', - }, - { jobId: annotation.job_id, error: JSON.stringify(resp) } + values: { jobId: annotation.job_id, error: JSON.stringify(resp) }, + } ) ); } else { toastNotifications.addDanger( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage', defaultMessage: 'An error occurred updating the annotation for job with ID {jobId}: {error}', - }, - { jobId: annotation.job_id, error: JSON.stringify(resp) } + values: { jobId: annotation.job_id, error: JSON.stringify(resp) }, + } ) ); } @@ -219,7 +211,7 @@ class AnnotationFlyoutIntl extends Component ANNOTATION_MAX_LENGTH_CHARS * lengthRatioToShowWarning ) { - helpText = intl.formatMessage( + helpText = i18n.translate( + 'xpack.ml.timeSeriesExplorer.annotationFlyout.approachingMaxLengthWarning', { - id: 'xpack.ml.timeSeriesExplorer.annotationFlyout.approachingMaxLengthWarning', defaultMessage: '{charsRemaining, number} {charsRemaining, plural, one {character} other {characters}} remaining', - }, - { charsRemaining: ANNOTATION_MAX_LENGTH_CHARS - annotation.annotation.length } + values: { charsRemaining: ANNOTATION_MAX_LENGTH_CHARS - annotation.annotation.length }, + } ); } @@ -344,7 +336,12 @@ class AnnotationFlyoutIntl extends Component = props => { + const annotationProp = useObservable(annotation$); + + if (annotationProp === undefined) { + return null; + } + + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap index 1d95c217e10f7..48cf53cf1ac01 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap @@ -89,6 +89,7 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` }, } } + tableLayout="fixed" /> `; diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index f270d14b53e56..6c4e8925f369f 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -42,7 +42,11 @@ import { isTimeSeriesViewJob, } from '../../../../../common/util/job_utils'; -import { annotation$, annotationsRefresh$ } from '../../../services/annotations_service'; +import { + annotation$, + annotationsRefresh$, + annotationsRefreshed, +} from '../../../services/annotations_service'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; @@ -136,7 +140,7 @@ const AnnotationsTable = injectI18n( this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() => this.getAnnotations() ); - annotationsRefresh$.next(true); + annotationsRefreshed(); } } @@ -150,7 +154,7 @@ const AnnotationsTable = injectI18n( this.state.isLoading === false && this.state.jobId !== this.props.jobs[0].job_id ) { - annotationsRefresh$.next(true); + annotationsRefreshed(); this.previousJobId = this.props.jobs[0].job_id; } } diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index d1beb360793f2..6728f019a6bd5 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -146,7 +146,7 @@ class AnomaliesTable extends Component { }; render() { - const { timefilter, tableData, filter, influencerFilter } = this.props; + const { bounds, tableData, filter, influencerFilter } = this.props; if ( tableData === undefined || @@ -175,7 +175,7 @@ class AnomaliesTable extends Component { tableData.examplesByJobId, this.isShowingAggregatedData(), tableData.interval, - timefilter, + bounds, tableData.showViewSeriesLink, this.state.showRuleEditorFlyout, this.state.itemIdToExpandedRowMap, @@ -195,6 +195,7 @@ class AnomaliesTable extends Component { return { onMouseOver: () => this.onMouseOverRow(item), onMouseLeave: () => this.onMouseLeaveRow(), + 'data-test-subj': `mlAnomaliesListRow row-${item.rowId}`, }; }; @@ -224,7 +225,7 @@ class AnomaliesTable extends Component { } } AnomaliesTable.propTypes = { - timefilter: PropTypes.object.isRequired, + bounds: PropTypes.object.isRequired, tableData: PropTypes.object, filter: PropTypes.func, influencerFilter: PropTypes.func, diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js index 75941edddeb56..5454911673fe2 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js @@ -55,7 +55,7 @@ export function getColumns( examplesByJobId, isAggregatedData, interval, - timefilter, + bounds, showViewSeriesLink, showRuleEditorFlyout, itemIdToExpandedRowMap, @@ -80,11 +80,13 @@ export function getColumns( }) } data-row-id={item.rowId} + data-test-subj="mlJobListRowDetailsToggle" /> ), }, { field: 'time', + 'data-test-subj': 'mlAnomaliesListColumnTime', name: i18n.translate('xpack.ml.anomaliesTable.timeColumnName', { defaultMessage: 'time', }), @@ -95,6 +97,7 @@ export function getColumns( }, { field: 'severity', + 'data-test-subj': 'mlAnomaliesListColumnSeverity', name: i18n.translate('xpack.ml.anomaliesTable.severityColumnName', { defaultMessage: 'severity', }), @@ -105,6 +108,7 @@ export function getColumns( }, { field: 'detector', + 'data-test-subj': 'mlAnomaliesListColumnDetector', name: i18n.translate('xpack.ml.anomaliesTable.detectorColumnName', { defaultMessage: 'detector', }), @@ -119,6 +123,7 @@ export function getColumns( if (items.some(item => item.entityValue !== undefined)) { columns.push({ field: 'entityValue', + 'data-test-subj': 'mlAnomaliesListColumnFoundFor', name: i18n.translate('xpack.ml.anomaliesTable.entityValueColumnName', { defaultMessage: 'found for', }), @@ -138,6 +143,7 @@ export function getColumns( if (items.some(item => item.influencers !== undefined)) { columns.push({ field: 'influencers', + 'data-test-subj': 'mlAnomaliesListColumnInfluencers', name: i18n.translate('xpack.ml.anomaliesTable.influencersColumnName', { defaultMessage: 'influenced by', }), @@ -159,6 +165,7 @@ export function getColumns( if (items.some(item => item.actual !== undefined)) { columns.push({ field: 'actualSort', + 'data-test-subj': 'mlAnomaliesListColumnActual', name: i18n.translate('xpack.ml.anomaliesTable.actualSortColumnName', { defaultMessage: 'actual', }), @@ -176,6 +183,7 @@ export function getColumns( if (items.some(item => item.typical !== undefined)) { columns.push({ field: 'typicalSort', + 'data-test-subj': 'mlAnomaliesListColumnTypical', name: i18n.translate('xpack.ml.anomaliesTable.typicalSortColumnName', { defaultMessage: 'typical', }), @@ -198,6 +206,7 @@ export function getColumns( if (nonTimeOfDayOrWeek === true) { columns.push({ field: 'metricDescriptionSort', + 'data-test-subj': 'mlAnomaliesListColumnDescription', name: i18n.translate('xpack.ml.anomaliesTable.metricDescriptionSortColumnName', { defaultMessage: 'description', }), @@ -213,6 +222,7 @@ export function getColumns( if (jobIds && jobIds.length > 1) { columns.push({ field: 'jobId', + 'data-test-subj': 'mlAnomaliesListColumnJobID', name: i18n.translate('xpack.ml.anomaliesTable.jobIdColumnName', { defaultMessage: 'job ID', }), @@ -223,6 +233,7 @@ export function getColumns( const showExamples = items.some(item => item.entityName === 'mlcategory'); if (showExamples === true) { columns.push({ + 'data-test-subj': 'mlAnomaliesListColumnCategoryExamples', name: i18n.translate('xpack.ml.anomaliesTable.categoryExamplesColumnName', { defaultMessage: 'category examples', }), @@ -254,6 +265,7 @@ export function getColumns( if (showLinks === true) { columns.push({ + 'data-test-subj': 'mlAnomaliesListColumnAction', name: i18n.translate('xpack.ml.anomaliesTable.actionsColumnName', { defaultMessage: 'actions', }), @@ -262,10 +274,10 @@ export function getColumns( return ( ); diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js index b4821ddb564c9..8cbee27bdd9a8 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js @@ -37,10 +37,10 @@ export const LinksMenu = injectI18n( class LinksMenu extends Component { static propTypes = { anomaly: PropTypes.object.isRequired, + bounds: PropTypes.object.isRequired, showViewSeriesLink: PropTypes.bool, isAggregatedData: PropTypes.bool, interval: PropTypes.string, - timefilter: PropTypes.object.isRequired, showRuleEditorFlyout: PropTypes.func, }; @@ -146,7 +146,7 @@ export const LinksMenu = injectI18n( viewSeries = () => { const record = this.props.anomaly.source; - const bounds = this.props.timefilter.getActiveBounds(); + const bounds = this.props.bounds; const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z const to = bounds.max.toISOString(); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js deleted file mode 100644 index 89a5fafc491b5..0000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * React component for a checkbox element to toggle charts display. - */ -import React, { Component } from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiCheckbox } from '@elastic/eui'; - -import makeId from '@elastic/eui/lib/components/form/form_row/make_id'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { injectObservablesAsProps } from '../../../util/observable_utils'; - -export const showCharts$ = new BehaviorSubject(true); - -class CheckboxShowChartsUnwrapped extends Component { - onChange = e => { - const showCharts = e.target.checked; - showCharts$.next(showCharts); - }; - - render() { - return ( - - } - checked={this.props.showCharts} - onChange={this.onChange} - /> - ); - } -} - -const CheckboxShowCharts = injectObservablesAsProps( - { - showCharts: showCharts$, - }, - CheckboxShowChartsUnwrapped -); - -export { CheckboxShowCharts }; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx new file mode 100644 index 0000000000000..70538d4dc3a91 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for a checkbox element to toggle charts display. + */ +import React, { FC } from 'react'; + +import { EuiCheckbox } from '@elastic/eui'; +// @ts-ignore +import makeId from '@elastic/eui/lib/components/form/form_row/make_id'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useUrlState } from '../../../util/url_state'; + +const SHOW_CHARTS_DEFAULT = true; +const SHOW_CHARTS_APP_STATE_NAME = 'mlShowCharts'; + +export const useShowCharts = () => { + const [appState, setAppState] = useUrlState('_a'); + + return [ + appState?.mlShowCharts !== undefined ? appState?.mlShowCharts : SHOW_CHARTS_DEFAULT, + (d: boolean) => setAppState(SHOW_CHARTS_APP_STATE_NAME, d), + ]; +}; + +export const CheckboxShowCharts: FC = () => { + const [showCharts, setShowCarts] = useShowCharts(); + + const onChange = (e: React.ChangeEvent) => { + setShowCarts(e.target.checked); + }; + + return ( + + } + checked={showCharts} + onChange={onChange} + /> + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts similarity index 75% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts index f26c16c6ff77d..d868b9570f337 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; +export { useShowCharts, CheckboxShowCharts } from './checkbox_showcharts'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/index.js deleted file mode 100644 index 26cb89d672632..0000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/index.js +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; -export { interval$, SelectInterval } from './select_interval'; -export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts b/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts new file mode 100644 index 0000000000000..f3e1ef8358867 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CheckboxShowCharts } from './checkbox_showcharts'; +export { SelectInterval } from './select_interval'; +export { SelectSeverity, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.ts similarity index 76% rename from x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.ts index b7957b807591c..32a0b53077818 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; +export { useTableInterval, SelectInterval } from './select_interval'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js deleted file mode 100644 index c99d25a68f722..0000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { SelectInterval } from './select_interval'; - -describe('SelectInterval', () => { - test('creates correct initial selected value', () => { - const wrapper = shallowWithIntl(); - const defaultSelectedValue = wrapper.props().interval.val; - - expect(defaultSelectedValue).toBe('auto'); - }); - - test('currently selected value is updated correctly on click', () => { - const wrapper = shallowWithIntl(); - const select = wrapper.first().shallow(); - - const defaultSelectedValue = wrapper.props().interval.val; - expect(defaultSelectedValue).toBe('auto'); - - select.simulate('change', { target: { value: 'day' } }); - const updatedSelectedValue = wrapper.props().interval.val; - expect(updatedSelectedValue).toBe('day'); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx new file mode 100644 index 0000000000000..e1861b887b2a9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router-dom'; +import { mount } from 'enzyme'; + +import { EuiSelect } from '@elastic/eui'; + +import { SelectInterval } from './select_interval'; + +describe('SelectInterval', () => { + test('creates correct initial selected value', () => { + const wrapper = mount( + + + + ); + const select = wrapper.find(EuiSelect); + + const defaultSelectedValue = select.props().value; + expect(defaultSelectedValue).toBe('auto'); + }); + + test('currently selected value is updated correctly on click', done => { + const wrapper = mount( + + + + ); + const select = wrapper.find(EuiSelect).first(); + const defaultSelectedValue = select.props().value; + expect(defaultSelectedValue).toBe('auto'); + + const onChange = select.props().onChange; + + act(() => { + if (onChange !== undefined) { + onChange({ target: { value: 'day' } } as React.ChangeEvent); + } + }); + + setImmediate(() => { + wrapper.update(); + const updatedSelect = wrapper.find(EuiSelect).first(); + const updatedSelectedValue = updatedSelect.props().value; + expect(updatedSelectedValue).toBe('day'); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx similarity index 56% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx index fce538c0c8c7e..cea3ef2a497b0 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -8,15 +8,18 @@ * React component for rendering a select element with various aggregation interval levels. */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { BehaviorSubject } from 'rxjs'; +import React, { FC } from 'react'; import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; +import { useUrlState } from '../../../util/url_state'; + +interface TableInterval { + display: string; + val: string; +} const OPTIONS = [ { @@ -41,13 +44,13 @@ const OPTIONS = [ }, ]; -function optionValueToInterval(value) { +function optionValueToInterval(value: string) { // Builds the corresponding interval object with the required display and val properties // from the specified value. const option = OPTIONS.find(opt => opt.value === value); // Default to auto if supplied value doesn't map to one of the options. - let interval = OPTIONS[0]; + let interval: TableInterval = { display: OPTIONS[0].text, val: OPTIONS[0].value }; if (option !== undefined) { interval = { display: option.text, val: option.value }; } @@ -55,30 +58,31 @@ function optionValueToInterval(value) { return interval; } -export const interval$ = new BehaviorSubject(optionValueToInterval(OPTIONS[0].value)); +const TABLE_INTERVAL_DEFAULT = optionValueToInterval('auto'); +const TABLE_INTERVAL_APP_STATE_NAME = 'mlSelectInterval'; -class SelectIntervalUnwrapped extends Component { - static propTypes = { - interval: PropTypes.object.isRequired, - }; +export const useTableInterval = () => { + const [appState, setAppState] = useUrlState('_a'); - onChange = e => { - const interval = optionValueToInterval(e.target.value); - interval$.next(interval); - }; + return [ + (appState && appState[TABLE_INTERVAL_APP_STATE_NAME]) || TABLE_INTERVAL_DEFAULT, + (d: TableInterval) => setAppState(TABLE_INTERVAL_APP_STATE_NAME, d), + ]; +}; - render() { - return ( - - ); - } -} +export const SelectInterval: FC = () => { + const [interval, setInterval] = useTableInterval(); -const SelectInterval = injectObservablesAsProps({ interval: interval$ }, SelectIntervalUnwrapped); + const onChange = (e: React.ChangeEvent) => { + setInterval(optionValueToInterval(e.target.value)); + }; -export { SelectInterval }; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.ts b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.ts new file mode 100644 index 0000000000000..1f524dc1c2ffd --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useTableSeverity, SelectSeverity, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js deleted file mode 100644 index 53d65d6622b94..0000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * React component for rendering a select element with threshold levels. - */ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; - -import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; - -const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { - defaultMessage: 'warning', -}); -const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { - defaultMessage: 'minor', -}); -const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { - defaultMessage: 'major', -}); -const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { - defaultMessage: 'critical', -}); - -const optionsMap = { - [warningLabel]: 0, - [minorLabel]: 25, - [majorLabel]: 50, - [criticalLabel]: 75, -}; - -export const SEVERITY_OPTIONS = [ - { - val: 0, - display: warningLabel, - color: getSeverityColor(0), - }, - { - val: 25, - display: minorLabel, - color: getSeverityColor(25), - }, - { - val: 50, - display: majorLabel, - color: getSeverityColor(50), - }, - { - val: 75, - display: criticalLabel, - color: getSeverityColor(75), - }, -]; - -function optionValueToThreshold(value) { - // Get corresponding threshold object with required display and val properties from the specified value. - let threshold = SEVERITY_OPTIONS.find(opt => opt.val === value); - - // Default to warning if supplied value doesn't map to one of the options. - if (threshold === undefined) { - threshold = SEVERITY_OPTIONS[0]; - } - - return threshold; -} - -export const severity$ = new BehaviorSubject(SEVERITY_OPTIONS[0]); - -class SelectSeverityUnwrapped extends Component { - onChange = valueDisplay => { - const threshold = optionValueToThreshold(optionsMap[valueDisplay]); - severity$.next(threshold); - }; - - getOptions = () => - SEVERITY_OPTIONS.map(({ color, display, val }) => ({ - value: display, - inputDisplay: ( - - - {display} - - - ), - dropdownDisplay: ( - - - {display} - - - -

- -

-
-
- ), - })); - - render() { - const { severity } = this.props; - const options = this.getOptions(); - - return ( - - ); - } -} - -SelectSeverityUnwrapped.propTypes = { - classNames: PropTypes.string, -}; - -SelectSeverityUnwrapped.defaultProps = { - classNames: '', -}; - -const SelectSeverity = injectObservablesAsProps({ severity: severity$ }, SelectSeverityUnwrapped); - -export { SelectSeverity }; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.tsx similarity index 55% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.tsx index ec2fe7d1cdeac..e30c48c10a194 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.tsx @@ -5,16 +5,25 @@ */ import React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router-dom'; +import { mount } from 'enzyme'; + +import { EuiSuperSelect } from '@elastic/eui'; + import { SelectSeverity } from './select_severity'; describe('SelectSeverity', () => { test('creates correct severity options and initial selected value', () => { - const wrapper = shallowWithIntl(); - const select = wrapper.first().shallow(); + const wrapper = mount( + + + + ); + const select = wrapper.find(EuiSuperSelect); - const options = select.instance().getOptions(); - const defaultSelectedValue = wrapper.props().severity.display; + const options = select.props().options; + const defaultSelectedValue = select.props().valueOfSelected; expect(defaultSelectedValue).toBe('warning'); expect(options.length).toEqual(4); @@ -53,15 +62,31 @@ describe('SelectSeverity', () => { ); }); - test('state for currently selected value is updated correctly on click', () => { - const wrapper = shallowWithIntl(); - const select = wrapper.first().shallow(); + test('state for currently selected value is updated correctly on click', done => { + const wrapper = mount( + + + + ); - const defaultSelectedValue = wrapper.props().severity.display; + const select = wrapper.find(EuiSuperSelect).first(); + const defaultSelectedValue = select.props().valueOfSelected; expect(defaultSelectedValue).toBe('warning'); - select.simulate('change', 'critical'); - const updatedSelectedValue = wrapper.props().severity.display; - expect(updatedSelectedValue).toBe('critical'); + const onChange = select.props().onChange; + + act(() => { + if (onChange !== undefined) { + onChange('critical'); + } + }); + + setImmediate(() => { + wrapper.update(); + const updatedSelect = wrapper.find(EuiSuperSelect).first(); + const updatedSelectedValue = updatedSelect.props().valueOfSelected; + expect(updatedSelectedValue).toBe('critical'); + done(); + }); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx new file mode 100644 index 0000000000000..a03594a5f213e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering a select element with threshold levels. + */ +import React, { Fragment, FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; + +import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; +import { useUrlState } from '../../../util/url_state'; + +const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { + defaultMessage: 'warning', +}); +const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { + defaultMessage: 'minor', +}); +const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { + defaultMessage: 'major', +}); +const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { + defaultMessage: 'critical', +}); + +const optionsMap = { + [warningLabel]: 0, + [minorLabel]: 25, + [majorLabel]: 50, + [criticalLabel]: 75, +}; + +interface TableSeverity { + val: number; + display: string; + color: string; +} + +export const SEVERITY_OPTIONS: TableSeverity[] = [ + { + val: 0, + display: warningLabel, + color: getSeverityColor(0), + }, + { + val: 25, + display: minorLabel, + color: getSeverityColor(25), + }, + { + val: 50, + display: majorLabel, + color: getSeverityColor(50), + }, + { + val: 75, + display: criticalLabel, + color: getSeverityColor(75), + }, +]; + +function optionValueToThreshold(value: number) { + // Get corresponding threshold object with required display and val properties from the specified value. + let threshold = SEVERITY_OPTIONS.find(opt => opt.val === value); + + // Default to warning if supplied value doesn't map to one of the options. + if (threshold === undefined) { + threshold = SEVERITY_OPTIONS[0]; + } + + return threshold; +} + +const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0]; +const TABLE_SEVERITY_APP_STATE_NAME = 'mlSelectSeverity'; + +export const useTableSeverity = () => { + const [appState, setAppState] = useUrlState('_a'); + + return [ + (appState && appState[TABLE_SEVERITY_APP_STATE_NAME]) || TABLE_SEVERITY_DEFAULT, + (d: TableSeverity) => setAppState(TABLE_SEVERITY_APP_STATE_NAME, d), + ]; +}; + +const getSeverityOptions = () => + SEVERITY_OPTIONS.map(({ color, display, val }) => ({ + value: display, + inputDisplay: ( + + + {display} + + + ), + dropdownDisplay: ( + + + {display} + + + +

+ +

+
+
+ ), + })); + +interface Props { + classNames?: string; +} + +export const SelectSeverity: FC = ({ classNames } = { classNames: '' }) => { + const [severity, setSeverity] = useTableSeverity(); + + const onChange = (valueDisplay: string) => { + setSeverity(optionValueToThreshold(optionsMap[valueDisplay])); + }; + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/display_value/display_value.tsx b/x-pack/legacy/plugins/ml/public/application/components/display_value/display_value.tsx index cfe3d09a16320..36225cb839704 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/display_value/display_value.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/display_value/display_value.tsx @@ -13,11 +13,11 @@ export const DisplayValue: FC<{ value: any }> = ({ value }) => { const length = String(value).length; if (length <= MAX_CHARS) { - return value; + return {value}; } else { return ( - {value} + {value} ); } diff --git a/x-pack/legacy/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss b/x-pack/legacy/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss index 0fa087deacf91..75118266d45db 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss +++ b/x-pack/legacy/plugins/ml/public/application/components/field_title_bar/_field_title_bar.scss @@ -1,9 +1,14 @@ .ml-field-title-bar { - color: $euiColorEmptyShade; - @include euiFontSizeL; + @include euiFontSizeM; + font-family: Roboto Mono, serif; + font-style: normal; + font-weight: bold; + font-size: $euiFontSizeS; + border-radius: $euiBorderRadius $euiBorderRadius 0 0; + padding: $euiSizeXS; + margin: (-$euiSize) (-$euiSize) 0 (-$euiSize); + border-top: 3px solid; text-align: center; - border-radius: $euiBorderRadius $euiBorderRadius 0px 0px; - padding-bottom: $euiSizeXS/2; .field-type-icon { vertical-align: middle; @@ -18,5 +23,6 @@ padding-right: $euiSizeS; max-width: 290px; // SASSTODO: Calculate value display: inline-block; + margin-left: $euiSizeS; } } diff --git a/x-pack/legacy/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss b/x-pack/legacy/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss index ba318057bcae1..864df28f2c055 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss +++ b/x-pack/legacy/plugins/ml/public/application/components/field_type_icon/_field_type_icon.scss @@ -1,8 +1,18 @@ +$icon-size: 20px; + .field-type-icon-container { - display: inline !important; + display: inline-block !important; + vertical-align: middle; + border: 1px solid; + border-radius: 4px; + width: $icon-size; + height: $icon-size; + line-height: $icon-size;; + text-align: center; .field-type-icon { - padding-right: $euiSizeXS / 2; + padding: 0; display: inline !important; + vertical-align: initial; } } diff --git a/x-pack/legacy/plugins/ml/public/application/components/influencers_list/influencers_list.js b/x-pack/legacy/plugins/ml/public/application/components/influencers_list/influencers_list.js index 6a395c5cbc114..ae61e65f91799 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/influencers_list/influencers_list.js +++ b/x-pack/legacy/plugins/ml/public/application/components/influencers_list/influencers_list.js @@ -56,7 +56,7 @@ function Influencer({ influencerFieldName, influencerFilter, valueData }) { const tooltipContent = getTooltipContent(maxScoreLabel, totalScoreLabel); return ( -
+
{influencerFieldName !== 'mlcategory' ? ( - +

{influencerFieldName}

diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/index.js b/x-pack/legacy/plugins/ml/public/application/components/job_selector/index.ts similarity index 100% rename from x-pack/legacy/plugins/ml/public/application/components/job_selector/index.js rename to x-pack/legacy/plugins/ml/public/application/components/job_selector/index.ts diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts deleted file mode 100644 index fe5966524c7e5..0000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject } from 'rxjs'; - -import { State } from 'ui/state_management/state'; - -export declare type JobSelectService$ = BehaviorSubject<{ - selection: string[]; - groups: string[]; - resetSelection: boolean; -}>; - -declare interface JobSelectService { - jobSelectService$: JobSelectService$; - unsubscribeFromGlobalState(): void; -} - -export const jobSelectServiceFactory: (globalState: State) => JobSelectService; diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js deleted file mode 100644 index 7f5c146568648..0000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { difference, isEqual } from 'lodash'; -import { BehaviorSubject } from 'rxjs'; -import { toastNotifications } from 'ui/notify'; -import { i18n } from '@kbn/i18n'; -import moment from 'moment'; -import d3 from 'd3'; - -import { mlJobService } from '../../services/job_service'; - -function warnAboutInvalidJobIds(invalidIds) { - if (invalidIds.length > 0) { - toastNotifications.addWarning( - i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { - defaultMessage: `Requested -{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, - values: { - invalidIdsLength: invalidIds.length, - invalidIds, - }, - }) - ); - } -} - -// check that the ids read from the url exist by comparing them to the -// jobs loaded via mlJobsService. -function getInvalidJobIds(ids) { - return ids.filter(id => { - const jobExists = mlJobService.jobs.some(job => job.job_id === id); - return jobExists === false && id !== '*'; - }); -} - -export const jobSelectServiceFactory = globalState => { - const { jobIds, selectedGroups } = getSelectedJobIds(globalState); - const jobSelectService$ = new BehaviorSubject({ - selection: jobIds, - groups: selectedGroups, - resetSelection: false, - }); - - // Subscribe to changes to globalState and trigger - // a jobSelectService update if the job selection changed. - const listener = () => { - const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState); - const oldSelectedJobIds = jobSelectService$.getValue().selection; - - if (newJobIds && !isEqual(oldSelectedJobIds, newJobIds)) { - jobSelectService$.next({ selection: newJobIds, groups: newSelectedGroups }); - } - }; - - globalState.on('save_with_changes', listener); - - const unsubscribeFromGlobalState = () => { - globalState.off('save_with_changes', listener); - }; - - return { jobSelectService$, unsubscribeFromGlobalState }; -}; - -function loadJobIdsFromGlobalState(globalState) { - // jobIds, groups - // fetch to get the latest state - globalState.fetch(); - - const jobIds = []; - let groups = []; - - if (globalState.ml && globalState.ml.jobIds) { - let tempJobIds = []; - groups = globalState.ml.groups || []; - - if (typeof globalState.ml.jobIds === 'string') { - tempJobIds.push(globalState.ml.jobIds); - } else { - tempJobIds = globalState.ml.jobIds; - } - tempJobIds = tempJobIds.map(id => String(id)); - - const invalidIds = getInvalidJobIds(tempJobIds); - warnAboutInvalidJobIds(invalidIds); - - let validIds = difference(tempJobIds, invalidIds); - // if there are no valid ids, warn and then select the first job - if (validIds.length === 0) { - toastNotifications.addWarning( - i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', { - defaultMessage: 'No jobs selected, auto selecting first job', - }) - ); - - if (mlJobService.jobs.length) { - validIds = [mlJobService.jobs[0].job_id]; - } - } - jobIds.push(...validIds); - } else { - // no jobs selected, use the first in the list - if (mlJobService.jobs.length) { - jobIds.push(mlJobService.jobs[0].job_id); - } - } - return { jobIds, selectedGroups: groups }; -} - -// TODO: -// Merge `setGlobalStateSkipRefresh()` and `setGlobalState()` into -// a single function similar to how we do `appStateHandler()`. -// When changing jobs in job selector it would trigger multiple events -// which in return would be consumed by Single Metric Viewer and could cause -// race conditions when updating the whole page. Because we don't control -// the internals of the involved timefilter event triggering, we use -// a global `skipRefresh` to control when Single Metric Viewer should -// skip updates triggered by timefilter. -export function setGlobalStateSkipRefresh(globalState, skipRefresh) { - globalState.fetch(); - if (globalState.ml === undefined) { - globalState.ml = {}; - } - globalState.ml.skipRefresh = skipRefresh; - globalState.save(); -} - -export function setGlobalState(globalState, { selectedIds, selectedGroups, skipRefresh }) { - globalState.fetch(); - if (globalState.ml === undefined) { - globalState.ml = {}; - } - globalState.ml.jobIds = selectedIds; - globalState.ml.groups = selectedGroups || []; - globalState.ml.skipRefresh = !!skipRefresh; - globalState.save(); -} - -// called externally to retrieve the selected jobs ids -export function getSelectedJobIds(globalState) { - return loadJobIdsFromGlobalState(globalState); -} - -export function getGroupsFromJobs(jobs) { - const groups = {}; - const groupsMap = {}; - - jobs.forEach(job => { - // Organize job by group - if (job.groups !== undefined) { - job.groups.forEach(g => { - if (groups[g] === undefined) { - groups[g] = { - id: g, - jobIds: [job.job_id], - timeRange: { - to: job.timeRange.to, - toMoment: null, - from: job.timeRange.from, - fromMoment: null, - fromPx: job.timeRange.fromPx, - toPx: job.timeRange.toPx, - widthPx: null, - }, - }; - - groupsMap[g] = [job.job_id]; - } else { - groups[g].jobIds.push(job.job_id); - groupsMap[g].push(job.job_id); - // keep track of earliest 'from' / latest 'to' for group range - if (groups[g].timeRange.to === null || job.timeRange.to > groups[g].timeRange.to) { - groups[g].timeRange.to = job.timeRange.to; - groups[g].timeRange.toMoment = job.timeRange.toMoment; - } - if (groups[g].timeRange.from === null || job.timeRange.from < groups[g].timeRange.from) { - groups[g].timeRange.from = job.timeRange.from; - groups[g].timeRange.fromMoment = job.timeRange.fromMoment; - } - if (groups[g].timeRange.toPx === null || job.timeRange.toPx > groups[g].timeRange.toPx) { - groups[g].timeRange.toPx = job.timeRange.toPx; - } - if ( - groups[g].timeRange.fromPx === null || - job.timeRange.fromPx < groups[g].timeRange.fromPx - ) { - groups[g].timeRange.fromPx = job.timeRange.fromPx; - } - } - }); - } - }); - - Object.keys(groups).forEach(groupId => { - const group = groups[groupId]; - group.timeRange.widthPx = group.timeRange.toPx - group.timeRange.fromPx; - group.timeRange.toMoment = moment(group.timeRange.to); - group.timeRange.fromMoment = moment(group.timeRange.from); - // create label - const fromString = group.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); - const toString = group.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); - group.timeRange.label = i18n.translate('xpack.ml.jobSelectList.groupTimeRangeLabel', { - defaultMessage: '{fromString} to {toString}', - values: { - fromString, - toString, - }, - }); - }); - - return { groups: Object.keys(groups).map(g => groups[g]), groupsMap }; -} - -export function normalizeTimes(jobs, dateFormatTz, ganttBarWidth) { - const jobsWithTimeRange = jobs.filter(job => { - return job.timeRange.to !== undefined && job.timeRange.from !== undefined; - }); - - const min = Math.min(...jobsWithTimeRange.map(job => +job.timeRange.from)); - const max = Math.max(...jobsWithTimeRange.map(job => +job.timeRange.to)); - const ganttScale = d3.scale - .linear() - .domain([min, max]) - .range([1, ganttBarWidth]); - - jobs.forEach(job => { - if (job.timeRange.to !== undefined && job.timeRange.from !== undefined) { - job.timeRange.fromPx = ganttScale(job.timeRange.from); - job.timeRange.toPx = ganttScale(job.timeRange.to); - job.timeRange.widthPx = job.timeRange.toPx - job.timeRange.fromPx; - // Ensure at least 1 px in width so it's always visible - if (job.timeRange.widthPx < 1) { - job.timeRange.widthPx = 1; - } - - job.timeRange.toMoment = moment(job.timeRange.to).tz(dateFormatTz); - job.timeRange.fromMoment = moment(job.timeRange.from).tz(dateFormatTz); - - const fromString = job.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); - const toString = job.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); - job.timeRange.label = i18n.translate('xpack.ml.jobSelector.jobTimeRangeLabel', { - defaultMessage: '{fromString} to {toString}', - values: { - fromString, - toString, - }, - }); - } else { - job.timeRange.widthPx = 0; - job.timeRange.fromPx = 0; - job.timeRange.toPx = 0; - job.timeRange.label = i18n.translate('xpack.ml.jobSelector.noResultsForJobLabel', { - defaultMessage: 'No results', - }); - } - }); - return jobs; -} diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts new file mode 100644 index 0000000000000..1484f0a391b67 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import d3 from 'd3'; + +import { Dictionary } from '../../../../common/types/common'; +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + +export function getGroupsFromJobs(jobs: MlJobWithTimeRange[]) { + const groups: Dictionary = {}; + const groupsMap: Dictionary = {}; + + jobs.forEach(job => { + // Organize job by group + if (job.groups !== undefined) { + job.groups.forEach(g => { + if (groups[g] === undefined) { + groups[g] = { + id: g, + jobIds: [job.job_id], + timeRange: { + to: job.timeRange.to, + toMoment: null, + from: job.timeRange.from, + fromMoment: null, + fromPx: job.timeRange.fromPx, + toPx: job.timeRange.toPx, + widthPx: null, + }, + }; + + groupsMap[g] = [job.job_id]; + } else { + groups[g].jobIds.push(job.job_id); + groupsMap[g].push(job.job_id); + // keep track of earliest 'from' / latest 'to' for group range + if (groups[g].timeRange.to === null || job.timeRange.to > groups[g].timeRange.to) { + groups[g].timeRange.to = job.timeRange.to; + groups[g].timeRange.toMoment = job.timeRange.toMoment; + } + if (groups[g].timeRange.from === null || job.timeRange.from < groups[g].timeRange.from) { + groups[g].timeRange.from = job.timeRange.from; + groups[g].timeRange.fromMoment = job.timeRange.fromMoment; + } + if (groups[g].timeRange.toPx === null || job.timeRange.toPx > groups[g].timeRange.toPx) { + groups[g].timeRange.toPx = job.timeRange.toPx; + } + if ( + groups[g].timeRange.fromPx === null || + job.timeRange.fromPx < groups[g].timeRange.fromPx + ) { + groups[g].timeRange.fromPx = job.timeRange.fromPx; + } + } + }); + } + }); + + Object.keys(groups).forEach(groupId => { + const group = groups[groupId]; + group.timeRange.widthPx = group.timeRange.toPx - group.timeRange.fromPx; + group.timeRange.toMoment = moment(group.timeRange.to); + group.timeRange.fromMoment = moment(group.timeRange.from); + // create label + const fromString = group.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); + const toString = group.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); + group.timeRange.label = i18n.translate('xpack.ml.jobSelectList.groupTimeRangeLabel', { + defaultMessage: '{fromString} to {toString}', + values: { + fromString, + toString, + }, + }); + }); + + return { groups: Object.keys(groups).map(g => groups[g]), groupsMap }; +} + +export function getTimeRangeFromSelection(jobs: MlJobWithTimeRange[], selection: string[]) { + if (jobs.length > 0) { + const times: number[] = []; + jobs.forEach(job => { + if (selection.includes(job.job_id)) { + if (job.timeRange.from !== undefined) { + times.push(job.timeRange.from); + } + if (job.timeRange.to !== undefined) { + times.push(job.timeRange.to); + } + } + }); + if (times.length) { + const extent = d3.extent(times); + const selectedTime = { + from: moment(extent[0]).toISOString(), + to: moment(extent[1]).toISOString(), + }; + return selectedTime; + } + } +} + +export function normalizeTimes( + jobs: MlJobWithTimeRange[], + dateFormatTz: string, + ganttBarWidth: number +) { + const jobsWithTimeRange = jobs.filter(job => { + return job.timeRange.to !== undefined && job.timeRange.from !== undefined; + }); + + const min = Math.min(...jobsWithTimeRange.map(job => +job.timeRange.from)); + const max = Math.max(...jobsWithTimeRange.map(job => +job.timeRange.to)); + const ganttScale = d3.scale + .linear() + .domain([min, max]) + .range([1, ganttBarWidth]); + + jobs.forEach(job => { + if (job.timeRange.to !== undefined && job.timeRange.from !== undefined) { + job.timeRange.fromPx = ganttScale(job.timeRange.from); + job.timeRange.toPx = ganttScale(job.timeRange.to); + job.timeRange.widthPx = job.timeRange.toPx - job.timeRange.fromPx; + // Ensure at least 1 px in width so it's always visible + if (job.timeRange.widthPx < 1) { + job.timeRange.widthPx = 1; + } + + job.timeRange.toMoment = moment(job.timeRange.to).tz(dateFormatTz); + job.timeRange.fromMoment = moment(job.timeRange.from).tz(dateFormatTz); + + const fromString = job.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); + const toString = job.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); + job.timeRange.label = i18n.translate('xpack.ml.jobSelector.jobTimeRangeLabel', { + defaultMessage: '{fromString} to {toString}', + values: { + fromString, + toString, + }, + }); + } else { + job.timeRange.widthPx = 0; + job.timeRange.fromPx = 0; + job.timeRange.toPx = 0; + job.timeRange.label = i18n.translate('xpack.ml.jobSelector.noResultsForJobLabel', { + defaultMessage: 'No results', + }); + } + }); + return jobs; +} diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.js b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.tsx similarity index 74% rename from x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.js rename to x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.tsx index b86118c451bb7..f1d9dcb0ec795 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.js +++ b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,23 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash'; import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { PropTypes } from 'prop-types'; -import moment from 'moment'; +import PropTypes from 'prop-types'; -import { ml } from '../../services/ml_api_service'; -import { JobSelectorTable } from './job_selector_table'; -import { IdBadges } from './id_badges'; -import { NewSelectionIdBadges } from './new_selection_id_badges'; -import { timefilter } from 'ui/timefilter'; -import { - getGroupsFromJobs, - normalizeTimes, - setGlobalState, - setGlobalStateSkipRefresh, -} from './job_select_service_utils'; -import { toastNotifications } from 'ui/notify'; import { EuiButton, EuiButtonEmpty, @@ -33,15 +19,42 @@ import { EuiSwitch, EuiTitle, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; -function mergeSelection(jobIds, groupObjs, singleSelection) { +import { toastNotifications } from 'ui/notify'; + +import { Dictionary } from '../../../../common/types/common'; +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; +import { ml } from '../../services/ml_api_service'; +import { useUrlState } from '../../util/url_state'; +// @ts-ignore +import { JobSelectorTable } from './job_selector_table'; +// @ts-ignore +import { IdBadges } from './id_badges'; +// @ts-ignore +import { NewSelectionIdBadges } from './new_selection_id_badges'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; + +interface GroupObj { + groupId: string; + jobIds: string[]; +} +function mergeSelection( + jobIds: string[], + groupObjs: GroupObj[], + singleSelection: boolean +): string[] { if (singleSelection) { return jobIds; } - const selectedIds = []; - const alreadySelected = []; + const selectedIds: string[] = []; + const alreadySelected: string[] = []; groupObjs.forEach(group => { selectedIds.push(group.groupId); @@ -58,8 +71,9 @@ function mergeSelection(jobIds, groupObjs, singleSelection) { return selectedIds; } -function getInitialGroupsMap(selectedGroups) { - const map = {}; +type GroupsMap = Dictionary; +function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { + const map: GroupsMap = {}; if (selectedGroups.length) { selectedGroups.forEach(group => { @@ -73,17 +87,20 @@ function getInitialGroupsMap(selectedGroups) { const BADGE_LIMIT = 10; const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels -export function JobSelector({ - dateFormatTz, - globalState, - jobSelectService$, - selectedJobIds, - selectedGroups, - singleSelection, - timeseriesOnly, -}) { - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); +interface JobSelectorProps { + dateFormatTz: string; + singleSelection: boolean; + timeseriesOnly: boolean; +} + +export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { + const [globalState, setGlobalState] = useUrlState('_g'); + + const selectedJobIds = globalState?.ml?.jobIds ?? []; + const selectedGroups = globalState?.ml?.groups ?? []; + + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) @@ -96,20 +113,12 @@ export function JobSelector({ const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const flyoutEl = useRef(null); + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { - // listen for update from Single Metric Viewer - const subscription = jobSelectService$.subscribe(({ selection, resetSelection }) => { - if (resetSelection === true) { - setSelectedIds(selection); - } - }); - - return function cleanup() { - subscription.unsubscribe(); - }; - }, []); // eslint-disable-line + setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); + }, [JSON.stringify([selectedJobIds, selectedGroups])]); // Ensure current selected ids always show up in flyout useEffect(() => { @@ -121,7 +130,9 @@ export function JobSelector({ const handleResize = useCallback(() => { if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { // get all cols in flyout table - const tableHeaderCols = flyoutEl.current.flyout.querySelectorAll('table thead th'); + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); // get the width of the last col const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); @@ -145,21 +156,12 @@ export function JobSelector({ handleResize(); }, [handleResize, jobs]); - // On opening and closing the flyout, optionally update a global `skipRefresh` flag. - // This allows us to circumvent race conditions which could happen by triggering both - // timefilter and job selector related events in Single Metric Viewer. - function closeFlyout(setSkipRefresh = true) { + function closeFlyout() { setIsFlyoutVisible(false); - if (setSkipRefresh) { - setGlobalStateSkipRefresh(globalState, false); - } } - function showFlyout(setSkipRefresh = true) { + function showFlyout() { setIsFlyoutVisible(true); - if (setSkipRefresh) { - setGlobalStateSkipRefresh(globalState, true); - } } function handleJobSelectionClick() { @@ -174,8 +176,8 @@ export function JobSelector({ setGroups(groupsWithTimerange); setMaps({ groupsMap, jobsMap: resp.jobsMap }); }) - .catch(err => { - console.log('Error fetching jobs', err); + .catch((err: any) => { + console.error('Error fetching jobs with time range', err); // eslint-disable-line toastNotifications.addDanger({ title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', @@ -184,14 +186,14 @@ export function JobSelector({ }); } - function handleNewSelection({ selectionFromTable }) { + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { setNewSelection(selectionFromTable); } function applySelection() { // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection = []; - const groupSelection = []; + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; newSelection.forEach(id => { if (maps.groupsMap[id] !== undefined) { @@ -206,68 +208,29 @@ export function JobSelector({ // create a Set to remove duplicate values const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - const isPrevousSelection = isEqual( - { selectedJobIds, selectedGroups }, - { selectedJobIds: allNewSelectionUnique, selectedGroups: groupSelection } - ); - setSelectedIds(newSelection); setNewSelection([]); - // If the job selection is unchanged, then we close the modal and - // disable skipping the timefilter listener flag in globalState. - // If the job selection changed, this will not - // update skipRefresh yet to avoid firing multiple events via - // applyTimeRangeFromSelection() and setGlobalState(). - closeFlyout(isPrevousSelection); - - // If the job selection changed, then when - // calling `applyTimeRangeFromSelection()` here - // Single Metric Viewer will skip an update - // triggered by timefilter to avoid a race - // condition caused by the job update listener - // that's also going to be triggered. - applyTimeRangeFromSelection(allNewSelectionUnique); - - // Set `skipRefresh` again to `false` here so after - // both the time range and jobs have been updated - // Single Metric Viewer should again update itself. - setGlobalState(globalState, { - selectedIds: allNewSelectionUnique, - selectedGroups: groupSelection, - skipRefresh: false, - }); - } + closeFlyout(); - function applyTimeRangeFromSelection(selection) { - if (applyTimeRange && jobs.length > 0) { - const times = []; - jobs.forEach(job => { - if (selection.includes(job.job_id)) { - if (job.timeRange.from !== undefined) { - times.push(job.timeRange.from); - } - if (job.timeRange.to !== undefined) { - times.push(job.timeRange.to); - } - } - }); - if (times.length) { - const min = Math.min(...times); - const max = Math.max(...times); - timefilter.setTime({ - from: moment(min).toISOString(), - to: moment(max).toISOString(), - }); - } - } + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; + + setGlobalState({ + ml: { + jobIds: allNewSelectionUnique, + groups: groupSelection, + }, + ...(time !== undefined ? { time } : {}), + }); } function toggleTimerangeSwitch() { setApplyTimeRange(!applyTimeRange); } - function removeId(id) { + function removeId(id: string) { setNewSelection(newSelection.filter(item => item !== id)); } @@ -315,6 +278,7 @@ export function JobSelector({ if (isFlyoutVisible) { return ( + {`${id}${jobCount ? jobCount : ''}`} ); diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/use_job_selection.ts b/x-pack/legacy/plugins/ml/public/application/components/job_selector/use_job_selection.ts new file mode 100644 index 0000000000000..563156ea98055 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/job_selector/use_job_selection.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { difference } from 'lodash'; +import { useEffect } from 'react'; + +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; + +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + +import { useUrlState } from '../../util/url_state'; + +import { getTimeRangeFromSelection } from './job_select_service_utils'; + +// check that the ids read from the url exist by comparing them to the +// jobs loaded via mlJobsService. +function getInvalidJobIds(jobs: MlJobWithTimeRange[], ids: string[]) { + return ids.filter(id => { + const jobExists = jobs.some(job => job.job_id === id); + return jobExists === false && id !== '*'; + }); +} + +function warnAboutInvalidJobIds(invalidIds: string[]) { + if (invalidIds.length > 0) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { + defaultMessage: `Requested +{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, + values: { + invalidIdsLength: invalidIds.length, + invalidIds: invalidIds.join(), + }, + }) + ); + } +} + +export interface JobSelection { + jobIds: string[]; + selectedGroups: string[]; +} + +export const useJobSelection = (jobs: MlJobWithTimeRange[], dateFormatTz: string) => { + const [globalState, setGlobalState] = useUrlState('_g'); + + const jobSelection: JobSelection = { jobIds: [], selectedGroups: [] }; + + const ids = globalState?.ml?.jobIds || []; + const tmpIds = (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); + const invalidIds = getInvalidJobIds(jobs, tmpIds); + const validIds = difference(tmpIds, invalidIds); + validIds.sort(); + + jobSelection.jobIds = validIds; + jobSelection.selectedGroups = globalState?.ml?.groups ?? []; + + useEffect(() => { + warnAboutInvalidJobIds(invalidIds); + }, [invalidIds]); + + useEffect(() => { + // if there are no valid ids, warn and then select the first job + if (validIds.length === 0 && jobs.length > 0) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', { + defaultMessage: 'No jobs selected, auto selecting first job', + }) + ); + + const mlGlobalState = globalState?.ml || {}; + mlGlobalState.jobIds = [jobs[0].job_id]; + + const time = getTimeRangeFromSelection(jobs, mlGlobalState.jobIds); + + setGlobalState({ + ...{ ml: mlGlobalState }, + ...(time !== undefined ? { time } : {}), + }); + } + }, [jobs, validIds]); + + return jobSelection; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap index 217aa113fba4d..f3c825a66ee2f 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap @@ -23,6 +23,7 @@ exports[`FilterBar snapshot suggestions not shown 1`] = ` fullWidth={true} incremental={false} inputRef={[Function]} + isClearable={true} isLoading={false} onChange={[Function]} onClick={[Function]} @@ -88,6 +89,7 @@ exports[`FilterBar snapshot suggestions shown 1`] = ` fullWidth={true} incremental={false} inputRef={[Function]} + isClearable={true} isLoading={false} onChange={[Function]} onClick={[Function]} diff --git a/x-pack/legacy/plugins/ml/public/application/components/loading_indicator/loading_indicator.js b/x-pack/legacy/plugins/ml/public/application/components/loading_indicator/loading_indicator.js index e84ef2f87c3ba..20f4fb86b5372 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/loading_indicator/loading_indicator.js +++ b/x-pack/legacy/plugins/ml/public/application/components/loading_indicator/loading_indicator.js @@ -12,7 +12,11 @@ import { EuiLoadingChart, EuiSpacer } from '@elastic/eui'; export function LoadingIndicator({ height, label }) { height = height ? +height : 100; return ( -
+
{label && ( <> diff --git a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx index 20fa2cca41231..ac83d598f2382 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx @@ -5,8 +5,14 @@ */ import React, { FC, useState } from 'react'; +import { encode } from 'rison-node'; + import { EuiTabs, EuiTab, EuiLink } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; + +import { useUrlState } from '../../util/url_state'; + import { Tab } from './main_tabs'; import { TabId } from './navigation_menu'; @@ -67,6 +73,7 @@ enum TAB_TEST_SUBJECT { type TAB_TEST_SUBJECTS = keyof typeof TAB_TEST_SUBJECT; export const Tabs: FC = ({ tabId, mainTabId, disableLinks }) => { + const [globalState] = useUrlState('_g'); const [selectedTabId, setSelectedTabId] = useState(tabId); function onSelectedTabChanged(id: string) { setSelectedTabId(id); @@ -78,12 +85,16 @@ export const Tabs: FC = ({ tabId, mainTabId, disableLinks }) => { {tabs.map((tab: Tab) => { const id = tab.id; + // globalState (e.g. selected jobs and time range) should be retained when changing pages. + // appState will not be considered. + const fullGlobalStateString = globalState !== undefined ? `?_g=${encode(globalState)}` : ''; + return ( diff --git a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx index 523970dfe12f8..ca6146f3e23b5 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx @@ -6,11 +6,14 @@ import React, { FC, Fragment, useState, useEffect } from 'react'; import { Subscription } from 'rxjs'; -import { EuiSuperDatePicker } from '@elastic/eui'; +import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; import { TimeHistory } from 'ui/timefilter'; import { TimeRange } from 'src/plugins/data/public'; -import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; +import { + mlTimefilterRefresh$, + mlTimefilterTimeChange$, +} from '../../../services/timefilter_refresh_service'; import { useUiContext } from '../../../contexts/ui/use_ui_context'; interface Duration { @@ -29,6 +32,10 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistory) { }; } +function updateLastRefresh(timeRange: OnRefreshProps) { + mlTimefilterRefresh$.next({ lastRefresh: Date.now(), timeRange }); +} + export const TopNav: FC = () => { const { chrome, timefilter, timeHistory } = useUiContext(); const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(timeHistory); @@ -74,6 +81,7 @@ export const TopNav: FC = () => { timefilter.setTime(newTime); setTime(newTime); setRecentlyUsedRanges(getRecentlyUsedRanges()); + mlTimefilterTimeChange$.next({ lastRefresh: Date.now(), timeRange: { start, end } }); } function updateInterval({ @@ -104,7 +112,7 @@ export const TopNav: FC = () => { isAutoRefreshOnly={!isTimeRangeSelectorEnabled} refreshInterval={refreshInterval.value} onTimeChange={updateFilter} - onRefresh={() => mlTimefilterRefresh$.next()} + onRefresh={updateLastRefresh} onRefreshChange={updateInterval} recentlyUsedRanges={recentlyUsedRanges} dateFormat={dateFormat} diff --git a/x-pack/legacy/plugins/ml/public/application/components/rule_editor/__snapshots__/condition_expression.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/rule_editor/__snapshots__/condition_expression.test.js.snap index 177ba5019fbe3..43b4625e81f79 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/rule_editor/__snapshots__/condition_expression.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/rule_editor/__snapshots__/condition_expression.test.js.snap @@ -55,10 +55,6 @@ exports[`ConditionExpression renders with appliesTo, operator and value supplied } > ; @@ -19,8 +27,41 @@ export interface EsDoc extends Record { export const MAX_COLUMNS = 20; export const DEFAULT_REGRESSION_COLUMNS = 8; +export const BASIC_NUMERICAL_TYPES = new Set([ + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.INTEGER, + ES_FIELD_TYPES.SHORT, + ES_FIELD_TYPES.BYTE, +]); + +export const EXTENDED_NUMERICAL_TYPES = new Set([ + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.FLOAT, + ES_FIELD_TYPES.HALF_FLOAT, + ES_FIELD_TYPES.SCALED_FLOAT, +]); + const ML__ID_COPY = 'ml__id_copy'; +export const isKeywordAndTextType = (fieldName: string): boolean => { + const { fields } = newJobCapsService; + + const fieldType = fields.find(field => field.name === fieldName)?.type; + let isBothTypes = false; + + // If it's a keyword type - check if it has a corresponding text type + if (fieldType !== undefined && fieldType === ES_FIELD_TYPES.KEYWORD) { + const field = newJobCapsService.getFieldById(fieldName.replace(/\.keyword$/, '')); + isBothTypes = field !== null && field.type === ES_FIELD_TYPES.TEXT; + } else if (fieldType !== undefined && fieldType === ES_FIELD_TYPES.TEXT) { + // If text, check if has corresponding keyword type + const field = newJobCapsService.getFieldById(`${fieldName}.keyword`); + isBothTypes = field !== null && field.type === ES_FIELD_TYPES.KEYWORD; + } + + return isBothTypes; +}; + // Used to sort columns: // - string based columns are moved to the left // - followed by the outlier_score column @@ -90,10 +131,10 @@ export const sortRegressionResultsFields = ( if (b === predictedField) { return 1; } - if (a === dependentVariable) { + if (a === dependentVariable || a === dependentVariable.replace(/\.keyword$/, '')) { return -1; } - if (b === dependentVariable) { + if (b === dependentVariable || b === dependentVariable.replace(/\.keyword$/, '')) { return 1; } @@ -200,6 +241,50 @@ export function getFlattenedFields(obj: EsDocSource, resultsField: string): EsFi return flatDocFields.filter(f => f !== ML__ID_COPY); } +export const getDefaultFieldsFromJobCaps = ( + fields: Field[], + jobConfig: DataFrameAnalyticsConfig +): { selectedFields: Field[]; docFields: Field[] } => { + const fieldsObj = { selectedFields: [], docFields: [] }; + if (fields.length === 0) { + return fieldsObj; + } + + const dependentVariable = getDependentVar(jobConfig.analysis); + const type = newJobCapsService.getFieldById(dependentVariable)?.type; + const predictionFieldName = getPredictionFieldName(jobConfig.analysis); + // default is 'ml' + const resultsField = jobConfig.dest.results_field; + + const defaultPredictionField = `${dependentVariable}_prediction`; + const predictedField = `${resultsField}.${ + predictionFieldName ? predictionFieldName : defaultPredictionField + }`; + + const allFields: any = [ + { + id: `${resultsField}.is_training`, + name: `${resultsField}.is_training`, + type: ES_FIELD_TYPES.BOOLEAN, + }, + { id: predictedField, name: predictedField, type }, + ...fields, + ].sort(({ name: a }, { name: b }) => sortRegressionResultsFields(a, b, jobConfig)); + + let selectedFields = allFields + .slice(0, DEFAULT_REGRESSION_COLUMNS * 2) + .filter((field: any) => field.name === predictedField || !field.name.includes('.keyword')); + + if (selectedFields.length > DEFAULT_REGRESSION_COLUMNS) { + selectedFields = selectedFields.slice(0, DEFAULT_REGRESSION_COLUMNS); + } + + return { + selectedFields, + docFields: allFields, + }; +}; + export const getDefaultClassificationFields = ( docs: EsDoc[], jobConfig: DataFrameAnalyticsConfig @@ -290,11 +375,12 @@ export const getDefaultSelectableFields = (docs: EsDoc[], resultsField: string): .slice(0, MAX_COLUMNS); }; -export const toggleSelectedField = ( +export const toggleSelectedFieldSimple = ( selectedFields: EsFieldName[], column: EsFieldName ): EsFieldName[] => { const index = selectedFields.indexOf(column); + if (index === -1) { selectedFields.push(column); } else { @@ -302,3 +388,16 @@ export const toggleSelectedField = ( } return selectedFields; }; + +export const toggleSelectedField = (selectedFields: Field[], column: EsFieldName): Field[] => { + const index = selectedFields.map(field => field.name).indexOf(column); + if (index === -1) { + const columnField = newJobCapsService.getFieldById(column); + if (columnField !== null) { + selectedFields.push(columnField); + } + } else { + selectedFields.splice(index, 1); + } + return selectedFields; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts index f7794af8b5861..62ef73670d8f5 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -33,11 +33,13 @@ export { getDefaultSelectableFields, getDefaultRegressionFields, getDefaultClassificationFields, + getDefaultFieldsFromJobCaps, getFlattenedFields, sortColumns, sortRegressionResultsColumns, sortRegressionResultsFields, toggleSelectedField, + toggleSelectedFieldSimple, EsId, EsDoc, EsDocSource, diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx index f424ebee58120..95e1b15d548c1 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx @@ -14,6 +14,10 @@ import { ResultsTable } from './results_table'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; import { LoadingPanel } from '../loading_panel'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; +import { IIndexPattern } from '../../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { useKibanaContext } from '../../../../../contexts/kibana'; interface GetDataFrameAnalyticsResponse { count: number; @@ -31,6 +35,21 @@ export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( ); +const jobConfigErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationFetchError', + { + defaultMessage: + 'Unable to fetch results. An error occurred loading the job configuration data.', + } +); + +const jobCapsErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.jobCapsFetchError', + { + defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.", + } +); + interface Props { jobId: string; jobStatus: DATA_FRAME_TASK_STATE; @@ -39,8 +58,13 @@ interface Props { export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { const [jobConfig, setJobConfig] = useState(undefined); const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState(undefined); + const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState( + undefined + ); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const kibanaContext = useKibanaContext(); const loadJobConfig = async () => { setIsLoadingJobConfig(true); @@ -78,23 +102,41 @@ export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { loadJobConfig(); }, []); - if (jobConfigErrorMessage !== undefined) { + const initializeJobCapsService = async () => { + if (jobConfig !== undefined) { + try { + const sourceIndex = jobConfig.source.index[0]; + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); + if (indexPattern !== undefined) { + await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); + } + setIsInitialized(true); + } catch (e) { + if (e.message !== undefined) { + setJobCapsServiceErrorMessage(e.message); + } else { + setJobCapsServiceErrorMessage(JSON.stringify(e)); + } + } + } + }; + + useEffect(() => { + initializeJobCapsService(); + }, [JSON.stringify(jobConfig)]); + + if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) { return ( -

{jobConfigErrorMessage}

+

{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}

); @@ -103,12 +145,12 @@ export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { return ( {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( )} {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( = ({ jobConfig, jobStatus, searchQuery }) const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); - const kibanaContext = useKibanaContext(); const index = jobConfig.dest.index; - const sourceIndex = jobConfig.source.index[0]; const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' @@ -86,25 +80,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) setIsLoading(true); try { - const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; - const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); - - if (indexPattern !== undefined) { - await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); - // If dependent_variable is of type keyword and text .keyword suffix is required for evaluate endpoint - const { fields } = newJobCapsService; - const depVarFieldType = fields.find(field => field.name === dependentVariable)?.type; - - // If it's a keyword type - check if it has a corresponding text type - if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.KEYWORD) { - const field = newJobCapsService.getFieldById(dependentVariable.replace(/\.keyword$/, '')); - requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.TEXT; - } else if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.TEXT) { - // If text, check if has corresponding keyword type - const field = newJobCapsService.getFieldById(`${dependentVariable}.keyword`); - requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.KEYWORD; - } - } + requiresKeyword = isKeywordAndTextType(dependentVariable); } catch (e) { // Additional error handling due to missing field type is handled by loadEvalData console.error('Unable to load new field types', error); // eslint-disable-line no-console @@ -359,9 +335,9 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) - + = React.memo( ({ jobConfig, jobStatus, setEvaluateSearchQuery }) => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(25); - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [selectedFields, setSelectedFields] = useState([] as Field[]); + const [docFields, setDocFields] = useState([] as Field[]); const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [searchError, setSearchError] = useState(undefined); const [searchString, setSearchString] = useState(undefined); + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + + const dependentVariable = getDependentVar(jobConfig.analysis); + function toggleColumnsPopover() { setColumnsPopoverVisible(!isColumnsPopoverVisible); } @@ -99,147 +113,140 @@ export const ResultsTable: FC = React.memo( sortDirection, status, tableItems, - } = useExploreData(jobConfig, selectedFields, setSelectedFields); - - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]); - docFields.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig)); - docFieldsCount = docFields.length; - } - - const columns: Array> = []; - - if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { - columns.push( - ...selectedFields.sort(sortRegressionResultsColumns(tableItems[0], jobConfig)).map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.classificationExploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.classificationExploration.indexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - return d; - }; + } = useExploreData(jobConfig, selectedFields, setSelectedFields, setDocFields); + + const columns: Array> = selectedFields.map(field => { + const { type } = field; + const isNumber = + type !== undefined && + (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type)); + + const column: ColumnType = { + field: field.name, + name: field.name, + sortable: true, + truncateText: true, + }; - let columnType; + const render = (d: any, fullItem: EsDoc) => { + if (Array.isArray(d) && d.every(item => typeof item === 'string')) { + // If the cells data is an array of strings, return as a comma separated list. + // The list will get limited to 5 items with `…` at the end if there's more in the original array. + return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; + } else if (Array.isArray(d)) { + // If the cells data is an array of e.g. objects, display a 'array' badge with a + // tooltip that explains that this type of field is not supported in this table. + return ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.indexArrayBadgeContent', + { + defaultMessage: 'array', + } + )} + + + ); + } - if (tableItems.length > 0) { - columnType = typeof tableItems[0][k]; - } + return d; + }; - if (typeof columnType !== 'undefined') { - switch (columnType) { - case 'boolean': - column.dataType = 'boolean'; - break; - case 'Date': - column.align = 'right'; - column.render = (d: any) => - formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case 'number': - column.dataType = 'number'; - column.render = render; - break; - default: - column.render = render; - break; - } - } else { + if (isNumber) { + column.dataType = 'number'; + column.render = render; + } else if (typeof type !== 'undefined') { + switch (type) { + case ES_FIELD_TYPES.BOOLEAN: + column.dataType = ES_FIELD_TYPES.BOOLEAN; + break; + case ES_FIELD_TYPES.DATE: + column.align = 'right'; + column.render = (d: any) => { + if (d !== undefined) { + return formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); + } + return d; + }; + break; + default: column.render = render; - } + break; + } + } else { + column.render = render; + } - return column; - }) - ); - } + return column; + }); + + const docFieldsCount = docFields.length; useEffect(() => { - if (jobConfig !== undefined) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis - ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + sortField !== undefined && + sortDirection !== undefined && + selectedFields.some(field => field.name === sortField) + ) { + let field = sortField; + // If sorting by predictedField use dependentVar type + if (predictedFieldName === sortField) { + field = dependentVariable; + } + const requiresKeyword = isKeywordAndTextType(field); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; - const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ + field: sortField, + direction: sortDirection, + searchQuery, + requiresKeyword, + }); } }, [JSON.stringify(searchQuery)]); useEffect(() => { - // by default set the sorting to descending on the prediction field (`_prediction`). - // if that's not available sort ascending on the first column. - // also check if the current sorting field is still available. - if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis + // By default set sorting to descending on the prediction field (`_prediction`). + // if that's not available sort ascending on the first column. Check if the current sorting field is still available. + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + !selectedFields.some(field => field.name === sortField) + ) { + const predictedFieldSelected = selectedFields.some( + field => field.name === predictedFieldName ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; + // CHECK IF keyword suffix is needed (if predicted field is selected we have to check the dependent variable type) + let sortByField = predictedFieldSelected ? dependentVariable : selectedFields[0].name; + + const requiresKeyword = isKeywordAndTextType(sortByField); + + sortByField = predictedFieldSelected ? predictedFieldName : sortByField; + const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ field: sortByField, direction, searchQuery, requiresKeyword }); } - }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); + }, [ + jobConfig, + columns.length, + selectedFields.length, + sortField, + sortDirection, + tableItems.length, + ]); let sorting: SortingPropType = false; let onTableChange; @@ -261,7 +268,17 @@ export const ResultsTable: FC = React.memo( setPageSize(size); if (sort.field !== sortField || sort.direction !== sortDirection) { - loadExploreData({ ...sort, searchQuery }); + let field = sort.field; + // If sorting by predictedField use depVar for type check + if (predictedFieldName === sort.field) { + field = dependentVariable; + } + + loadExploreData({ + ...sort, + searchQuery, + requiresKeyword: isKeywordAndTextType(field), + }); } }; } @@ -422,14 +439,17 @@ export const ResultsTable: FC = React.memo( )}
- {docFields.map(d => ( + {docFields.map(({ name }) => ( toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} + key={name} + id={name} + label={name} + checked={selectedFields.some(field => field.name === name)} + onChange={() => toggleColumn(name)} + disabled={ + selectedFields.some(field => field.name === name) && + selectedFields.length === 1 + } /> ))}
diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts index ba12fcab98a36..b61f9f6524116 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts @@ -17,27 +17,22 @@ import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_m import { ml } from '../../../../../services/ml_api_service'; import { getNestedProperty } from '../../../../../util/object_utils'; -import { SavedSearchQuery } from '../../../../../contexts/kibana'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { Field } from '../../../../../../../common/types/fields'; +import { LoadExploreDataArg } from '../../../../common/analytics'; import { - getDefaultClassificationFields, + getDefaultFieldsFromJobCaps, getFlattenedFields, DataFrameAnalyticsConfig, EsFieldName, - getPredictedFieldName, INDEX_STATUS, SEARCH_SIZE, - defaultSearchQuery, SearchQuery, } from '../../../../common'; export type TableItem = Record; -interface LoadExploreDataArg { - field: string; - direction: SortDirection; - searchQuery: SavedSearchQuery; -} export interface UseExploreDataReturnType { errorMessage: string; loadExploreData: (arg: LoadExploreDataArg) => void; @@ -49,8 +44,9 @@ export interface UseExploreDataReturnType { export const useExploreData = ( jobConfig: DataFrameAnalyticsConfig | undefined, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> + selectedFields: Field[], + setSelectedFields: React.Dispatch>, + setDocFields: React.Dispatch> ): UseExploreDataReturnType => { const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(INDEX_STATUS.UNUSED); @@ -58,7 +54,26 @@ export const useExploreData = ( const [sortField, setSortField] = useState(''); const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); - const loadExploreData = async ({ field, direction, searchQuery }: LoadExploreDataArg) => { + const getDefaultSelectedFields = () => { + const { fields } = newJobCapsService; + + if (selectedFields.length === 0 && jobConfig !== undefined) { + const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps( + fields, + jobConfig + ); + + setSelectedFields(defaultSelected); + setDocFields(docFields); + } + }; + + const loadExploreData = async ({ + field, + direction, + searchQuery, + requiresKeyword, + }: LoadExploreDataArg) => { if (jobConfig !== undefined) { setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); @@ -72,7 +87,7 @@ export const useExploreData = ( if (field !== undefined) { body.sort = [ { - [field]: { + [`${field}${requiresKeyword ? '.keyword' : ''}`]: { order: direction, }, }, @@ -96,11 +111,6 @@ export const useExploreData = ( return; } - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultClassificationFields(docs, jobConfig); - setSelectedFields(newSelectedFields); - } - // Create a version of the doc's source with flattened field names. // This avoids confusion later on if a field name has dots in its name // or is a nested fields when displaying it via EuiInMemoryTable. @@ -144,11 +154,7 @@ export const useExploreData = ( useEffect(() => { if (jobConfig !== undefined) { - loadExploreData({ - field: getPredictedFieldName(jobConfig.dest.results_field, jobConfig.analysis), - direction: SORT_DIRECTION.DESC, - searchQuery: defaultSearchQuery, - }); + getDefaultSelectedFields(); } }, [jobConfig && jobConfig.id]); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx index 31e6d409b1c4f..9691a0706121c 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx @@ -46,7 +46,7 @@ import { ml } from '../../../../../services/ml_api_service'; import { sortColumns, - toggleSelectedField, + toggleSelectedFieldSimple, DataFrameAnalyticsConfig, EsFieldName, EsDoc, @@ -138,7 +138,7 @@ export const Exploration: FC = React.memo(({ jobId, jobStatus }) => { function toggleColumn(column: EsFieldName) { if (tableItems.length > 0 && jobConfig !== undefined) { // spread to a new array otherwise the component wouldn't re-render - setSelectedFields([...toggleSelectedField(selectedFields, column)]); + setSelectedFields([...toggleSelectedFieldSimple(selectedFields, column)]); } } diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx index 12a41e1e7d851..7399828bcd642 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx @@ -14,6 +14,10 @@ import { ResultsTable } from './results_table'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; import { LoadingPanel } from '../loading_panel'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; +import { IIndexPattern } from '../../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { useKibanaContext } from '../../../../../contexts/kibana'; interface GetDataFrameAnalyticsResponse { count: number; @@ -31,6 +35,21 @@ export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( ); +const jobConfigErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.jobConfigurationFetchError', + { + defaultMessage: + 'Unable to fetch results. An error occurred loading the job configuration data.', + } +); + +const jobCapsErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.jobCapsFetchError', + { + defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.", + } +); + interface Props { jobId: string; jobStatus: DATA_FRAME_TASK_STATE; @@ -39,8 +58,13 @@ interface Props { export const RegressionExploration: FC = ({ jobId, jobStatus }) => { const [jobConfig, setJobConfig] = useState(undefined); const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState(undefined); + const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState( + undefined + ); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const kibanaContext = useKibanaContext(); const loadJobConfig = async () => { setIsLoadingJobConfig(true); @@ -69,23 +93,41 @@ export const RegressionExploration: FC = ({ jobId, jobStatus }) => { loadJobConfig(); }, []); - if (jobConfigErrorMessage !== undefined) { + const initializeJobCapsService = async () => { + if (jobConfig !== undefined) { + try { + const sourceIndex = jobConfig.source.index[0]; + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); + if (indexPattern !== undefined) { + await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); + } + setIsInitialized(true); + } catch (e) { + if (e.message !== undefined) { + setJobCapsServiceErrorMessage(e.message); + } else { + setJobCapsServiceErrorMessage(JSON.stringify(e)); + } + } + } + }; + + useEffect(() => { + initializeJobCapsService(); + }, [JSON.stringify(jobConfig)]); + + if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) { return ( -

{jobConfigErrorMessage}

+

{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}

); @@ -94,12 +136,12 @@ export const RegressionExploration: FC = ({ jobId, jobStatus }) => { return ( {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( )} {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( = React.memo( ({ jobConfig, jobStatus, setEvaluateSearchQuery }) => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(25); - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [selectedFields, setSelectedFields] = useState([] as Field[]); + const [docFields, setDocFields] = useState([] as Field[]); const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [searchError, setSearchError] = useState(undefined); const [searchString, setSearchString] = useState(undefined); + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + + const dependentVariable = getDependentVar(jobConfig.analysis); + function toggleColumnsPopover() { setColumnsPopoverVisible(!isColumnsPopoverVisible); } @@ -100,147 +114,140 @@ export const ResultsTable: FC = React.memo( sortDirection, status, tableItems, - } = useExploreData(jobConfig, selectedFields, setSelectedFields); - - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]); - docFields.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig)); - docFieldsCount = docFields.length; - } - - const columns: Array> = []; - - if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { - columns.push( - ...selectedFields.sort(sortRegressionResultsColumns(tableItems[0], jobConfig)).map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.indexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - return d; - }; + } = useExploreData(jobConfig, selectedFields, setSelectedFields, setDocFields); + + const columns: Array> = selectedFields.map(field => { + const { type } = field; + const isNumber = + type !== undefined && + (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type)); + + const column: ColumnType = { + field: field.name, + name: field.name, + sortable: true, + truncateText: true, + }; - let columnType; + const render = (d: any, fullItem: EsDoc) => { + if (Array.isArray(d) && d.every(item => typeof item === 'string')) { + // If the cells data is an array of strings, return as a comma separated list. + // The list will get limited to 5 items with `…` at the end if there's more in the original array. + return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; + } else if (Array.isArray(d)) { + // If the cells data is an array of e.g. objects, display a 'array' badge with a + // tooltip that explains that this type of field is not supported in this table. + return ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent', + { + defaultMessage: 'array', + } + )} + + + ); + } - if (tableItems.length > 0) { - columnType = typeof tableItems[0][k]; - } + return d; + }; - if (typeof columnType !== 'undefined') { - switch (columnType) { - case 'boolean': - column.dataType = 'boolean'; - break; - case 'Date': - column.align = 'right'; - column.render = (d: any) => - formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case 'number': - column.dataType = 'number'; - column.render = render; - break; - default: - column.render = render; - break; - } - } else { + if (isNumber) { + column.dataType = 'number'; + column.render = render; + } else if (typeof type !== 'undefined') { + switch (type) { + case ES_FIELD_TYPES.BOOLEAN: + column.dataType = ES_FIELD_TYPES.BOOLEAN; + break; + case ES_FIELD_TYPES.DATE: + column.align = 'right'; + column.render = (d: any) => { + if (d !== undefined) { + return formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); + } + return d; + }; + break; + default: column.render = render; - } + break; + } + } else { + column.render = render; + } - return column; - }) - ); - } + return column; + }); + + const docFieldsCount = docFields.length; useEffect(() => { - if (jobConfig !== undefined) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis - ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + sortField !== undefined && + sortDirection !== undefined && + selectedFields.some(field => field.name === sortField) + ) { + let field = sortField; + // If sorting by predictedField use dependentVar type + if (predictedFieldName === sortField) { + field = dependentVariable; + } + const requiresKeyword = isKeywordAndTextType(field); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; - const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ + field: sortField, + direction: sortDirection, + searchQuery, + requiresKeyword, + }); } }, [JSON.stringify(searchQuery)]); useEffect(() => { - // by default set the sorting to descending on the prediction field (`_prediction`). - // if that's not available sort ascending on the first column. - // also check if the current sorting field is still available. - if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis + // By default set sorting to descending on the prediction field (`_prediction`). + // if that's not available sort ascending on the first column. Check if the current sorting field is still available. + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + !selectedFields.some(field => field.name === sortField) + ) { + const predictedFieldSelected = selectedFields.some( + field => field.name === predictedFieldName ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; + // CHECK IF keyword suffix is needed (if predicted field is selected we have to check the dependent variable type) + let sortByField = predictedFieldSelected ? dependentVariable : selectedFields[0].name; + + const requiresKeyword = isKeywordAndTextType(sortByField); + + sortByField = predictedFieldSelected ? predictedFieldName : sortByField; + const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ field: sortByField, direction, searchQuery, requiresKeyword }); } - }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); + }, [ + jobConfig, + columns.length, + selectedFields.length, + sortField, + sortDirection, + tableItems.length, + ]); let sorting: SortingPropType = false; let onTableChange; @@ -262,7 +269,17 @@ export const ResultsTable: FC = React.memo( setPageSize(size); if (sort.field !== sortField || sort.direction !== sortDirection) { - loadExploreData({ ...sort, searchQuery }); + let field = sort.field; + // If sorting by predictedField use depVar for type check + if (predictedFieldName === sort.field) { + field = dependentVariable; + } + + loadExploreData({ + ...sort, + searchQuery, + requiresKeyword: isKeywordAndTextType(field), + }); } }; } @@ -423,14 +440,16 @@ export const ResultsTable: FC = React.memo( )}
- {docFields.map(d => ( + {docFields.map(({ name }) => ( toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} + id={name} + label={name} + checked={selectedFields.some(field => field.name === name)} + onChange={() => toggleColumn(name)} + disabled={ + selectedFields.some(field => field.name === name) && + selectedFields.length === 1 + } /> ))}
diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts index 8e9cf45c14ec7..f6aa3e490f3ec 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts @@ -12,27 +12,22 @@ import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_m import { ml } from '../../../../../services/ml_api_service'; import { getNestedProperty } from '../../../../../util/object_utils'; -import { SavedSearchQuery } from '../../../../../contexts/kibana'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { - getDefaultRegressionFields, + getDefaultFieldsFromJobCaps, getFlattenedFields, DataFrameAnalyticsConfig, EsFieldName, - getPredictedFieldName, INDEX_STATUS, SEARCH_SIZE, - defaultSearchQuery, SearchQuery, } from '../../../../common'; +import { Field } from '../../../../../../../common/types/fields'; +import { LoadExploreDataArg } from '../../../../common/analytics'; export type TableItem = Record; -interface LoadExploreDataArg { - field: string; - direction: SortDirection; - searchQuery: SavedSearchQuery; -} export interface UseExploreDataReturnType { errorMessage: string; loadExploreData: (arg: LoadExploreDataArg) => void; @@ -44,8 +39,9 @@ export interface UseExploreDataReturnType { export const useExploreData = ( jobConfig: DataFrameAnalyticsConfig | undefined, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> + selectedFields: Field[], + setSelectedFields: React.Dispatch>, + setDocFields: React.Dispatch> ): UseExploreDataReturnType => { const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(INDEX_STATUS.UNUSED); @@ -53,7 +49,26 @@ export const useExploreData = ( const [sortField, setSortField] = useState(''); const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); - const loadExploreData = async ({ field, direction, searchQuery }: LoadExploreDataArg) => { + const getDefaultSelectedFields = () => { + const { fields } = newJobCapsService; + + if (selectedFields.length === 0 && jobConfig !== undefined) { + const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps( + fields, + jobConfig + ); + + setSelectedFields(defaultSelected); + setDocFields(docFields); + } + }; + + const loadExploreData = async ({ + field, + direction, + searchQuery, + requiresKeyword, + }: LoadExploreDataArg) => { if (jobConfig !== undefined) { setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); @@ -67,7 +82,7 @@ export const useExploreData = ( if (field !== undefined) { body.sort = [ { - [field]: { + [`${field}${requiresKeyword ? '.keyword' : ''}`]: { order: direction, }, }, @@ -91,11 +106,6 @@ export const useExploreData = ( return; } - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultRegressionFields(docs, jobConfig); - setSelectedFields(newSelectedFields); - } - // Create a version of the doc's source with flattened field names. // This avoids confusion later on if a field name has dots in its name // or is a nested fields when displaying it via EuiInMemoryTable. @@ -139,11 +149,7 @@ export const useExploreData = ( useEffect(() => { if (jobConfig !== undefined) { - loadExploreData({ - field: getPredictedFieldName(jobConfig.dest.results_field, jobConfig.analysis), - direction: SORT_DIRECTION.DESC, - searchQuery: defaultSearchQuery, - }); + getDefaultSelectedFields(); } }, [jobConfig && jobConfig.id]); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts index 337d3768f2408..51982541ccc3b 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts @@ -7,20 +7,7 @@ import { ES_FIELD_TYPES } from '../../../../../../../../../../../src/plugins/data/public'; import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; import { JOB_TYPES, AnalyticsJobType } from '../../hooks/use_create_analytics_form/state'; - -const BASIC_NUMERICAL_TYPES = new Set([ - ES_FIELD_TYPES.LONG, - ES_FIELD_TYPES.INTEGER, - ES_FIELD_TYPES.SHORT, - ES_FIELD_TYPES.BYTE, -]); - -const EXTENDED_NUMERICAL_TYPES = new Set([ - ES_FIELD_TYPES.DOUBLE, - ES_FIELD_TYPES.FLOAT, - ES_FIELD_TYPES.HALF_FLOAT, - ES_FIELD_TYPES.SCALED_FLOAT, -]); +import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../common/fields'; const CATEGORICAL_TYPES = new Set(['ip', 'keyword', 'text']); diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss index 39a87ece68ac9..2702817a55749 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/_field_stats_card.scss @@ -8,56 +8,96 @@ // These styles should all be removed once the file data visualizer is using // the same field_data_card component as the index based data visualizer. height: 408px; + box-shadow: none; + border-color: $euiBorderColor; + // Note the names of these styles need to match the type of the field they are displaying. .boolean { - background-color: #e6c220; + color: $euiColorVis5; + border-color: $euiColorVis5; + + .field-type-icon-container { + background-color: rgba($euiColorVis5, 0.5); + } } .date { - background-color: #f98510; + color: $euiColorVis7; + border-color: $euiColorVis7; + + .field-type-icon-container { + background-color: rgba($euiColorVis7, 0.5); + } } .document_count { - background-color: #db1374; + color: $euiColorVis2; + border-color: $euiColorVis2; + + .field-type-icon-container { + background-color: rgba($euiColorVis2, 0.5); + } } .geo_point { - background-color: #461a0a; + color: $euiColorVis8; + border-color: $euiColorVis8; + + .field-type-icon-container { + background-color: rgba($euiColorVis8, 0.5); + } } .ip { - background-color: #490092; + color: $euiColorVis3; + border-color: $euiColorVis3; + + .field-type-icon-container { + background-color: rgba($euiColorVis3, 0.5); + } } .keyword { - background-color: #00b3a4; + color: $euiColorVis0; + border-color: $euiColorVis0; + + .field-type-icon-container { + background-color: rgba($euiColorVis0, 0.5); + } } .number { - background-color: #3185fc; + color: $euiColorVis1; + border-color: $euiColorVis1; + + .field-type-icon-container { + background-color: rgba($euiColorVis1, 0.5); + } } .text { - background-color: #920000; + color: $euiColorVis9; + border-color: $euiColorVis9; + + .field-type-icon-container { + background-color: rgba($euiColorVis9, 0.5); + } } .type-other, .unknown { - background-color: #bfa180; + color: $euiColorVis6; + border-color: $euiColorVis6; + + .field-type-icon-container { + background-color: rgba($euiColorVis6, 0.5); + } } // Use euiPanel styling @include euiPanel($selector: '.card-contents'); - .card-contents { - height: 378px; - line-height: 21px; - border-radius: 0px 0px $euiBorderRadius $euiBorderRadius; - overflow: hidden; - } - .stats { - padding: 10px 10px 0px 10px; text-align: center; } diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js index b1167266a5d29..988fb653dd1ad 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/fields_stats/field_stats_card.js @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiSpacer, EuiPanel, EuiFlexGroup, EuiFlexItem, EuiText, EuiProgress } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { FieldTypeIcon } from '../../../../components/field_type_icon'; @@ -25,129 +25,136 @@ export function FieldStatsCard({ field }) { } return ( - -
-
-
- -
- {field.name} -
+ +
+
+ +
+ {field.name}
+
-
- {field.count > 0 && ( - -
-
-