diff --git a/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md new file mode 100644 index 00000000000000..ddbf9aafbd28a5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [chromeless](./kibana-plugin-public.appbase.chromeless.md) + +## AppBase.chromeless property + +Hide the UI chrome when the application is mounted. Defaults to `false`. Takes precedence over chrome service visibility settings. + +Signature: + +```typescript +chromeless?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.id.md b/docs/development/core/public/kibana-plugin-public.appbase.id.md index 57daa0c94bdf6b..89dd32d296104c 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.id.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.id.md @@ -4,6 +4,8 @@ ## AppBase.id property +The unique identifier of the application + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-public.appbase.md b/docs/development/core/public/kibana-plugin-public.appbase.md index a93a195c559b1b..eb6d91cb924888 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.md @@ -16,10 +16,14 @@ export interface AppBase | Property | Type | Description | | --- | --- | --- | | [capabilities](./kibana-plugin-public.appbase.capabilities.md) | Partial<Capabilities> | Custom capabilities defined by the app. | +| [chromeless](./kibana-plugin-public.appbase.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | | [icon](./kibana-plugin-public.appbase.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | -| [id](./kibana-plugin-public.appbase.id.md) | string | | +| [id](./kibana-plugin-public.appbase.id.md) | string | The unique identifier of the application | +| [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) | AppNavLinkStatus | The initial status of the application's navLink. Defaulting to visible if status is accessible and hidden if status is inaccessible See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | | [order](./kibana-plugin-public.appbase.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [status](./kibana-plugin-public.appbase.status.md) | AppStatus | The initial status of the application. Defaulting to accessible | | [title](./kibana-plugin-public.appbase.title.md) | string | The title of the application. | -| [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) | Observable<string> | An observable for a tooltip shown when hovering over app link. | +| [tooltip](./kibana-plugin-public.appbase.tooltip.md) | string | A tooltip shown when hovering over app link. | +| [updater$](./kibana-plugin-public.appbase.updater_.md) | Observable<AppUpdater> | An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. | diff --git a/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md new file mode 100644 index 00000000000000..d6744c3e757568 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) + +## AppBase.navLinkStatus property + +The initial status of the application's navLink. Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +Signature: + +```typescript +navLinkStatus?: AppNavLinkStatus; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md b/docs/development/core/public/kibana-plugin-public.appbase.status.md similarity index 56% rename from docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md rename to docs/development/core/public/kibana-plugin-public.appbase.status.md index 0767ead5f1455b..a5fbadbeea1ffc 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.status.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [status](./kibana-plugin-public.appbase.status.md) -## AppBase.tooltip$ property +## AppBase.status property -An observable for a tooltip shown when hovering over app link. +The initial status of the application. Defaulting to `accessible` Signature: ```typescript -tooltip$?: Observable; +status?: AppStatus; ``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md new file mode 100644 index 00000000000000..85921a5a321dd2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip](./kibana-plugin-public.appbase.tooltip.md) + +## AppBase.tooltip property + +A tooltip shown when hovering over app link. + +Signature: + +```typescript +tooltip?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.updater_.md b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md new file mode 100644 index 00000000000000..3edd3573834491 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md @@ -0,0 +1,44 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [updater$](./kibana-plugin-public.appbase.updater_.md) + +## AppBase.updater$ property + +An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. + +Signature: + +```typescript +updater$?: Observable; +``` + +## Example + +How to update an application navLink at runtime + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + }) + } + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.md index a63de399c2ecb4..cf9bc5189af409 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.md @@ -16,5 +16,6 @@ export interface ApplicationSetup | Method | Description | | --- | --- | | [register(app)](./kibana-plugin-public.applicationsetup.register.md) | Register an mountable application to the system. | +| [registerAppUpdater(appUpdater$)](./kibana-plugin-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | | [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md new file mode 100644 index 00000000000000..39b4f878a3f795 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md @@ -0,0 +1,47 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerAppUpdater](./kibana-plugin-public.applicationsetup.registerappupdater.md) + +## ApplicationSetup.registerAppUpdater() method + +Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime. + +This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the `updater$` property of the registered application instead. + +Signature: + +```typescript +registerAppUpdater(appUpdater$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appUpdater$ | Observable<AppUpdater> | | + +Returns: + +`void` + +## Example + +How to register an application updater that disables some applications: + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + setup({ application }) { + application.registerAppUpdater( + new BehaviorSubject(app => { + if (myPluginApi.shouldDisable(app)) + return { + status: AppStatus.inaccessible, + }; + }) + ); + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md new file mode 100644 index 00000000000000..d6b22ac2b92178 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +## AppNavLinkStatus enum + +Status of the application's navLink. + +Signature: + +```typescript +export declare enum AppNavLinkStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| default | 0 | The application navLink will be visible if the application's [AppStatus](./kibana-plugin-public.appstatus.md) is set to accessible and hidden if the application status is set to inaccessible. | +| disabled | 2 | The application navLink is visible but inactive and not clickable in the navigation bar. | +| hidden | 3 | The application navLink does not appear in the navigation bar. | +| visible | 1 | The application navLink is visible and clickable in the navigation bar. | + diff --git a/docs/development/core/public/kibana-plugin-public.appstatus.md b/docs/development/core/public/kibana-plugin-public.appstatus.md new file mode 100644 index 00000000000000..23fb7186569dad --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appstatus.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppStatus](./kibana-plugin-public.appstatus.md) + +## AppStatus enum + +Accessibility status of an application. + +Signature: + +```typescript +export declare enum AppStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| accessible | 0 | Application is accessible. | +| inaccessible | 1 | Application is not accessible. | + diff --git a/docs/development/core/public/kibana-plugin-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md new file mode 100644 index 00000000000000..b9260c79cd972b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) + +## AppUpdatableFields type + +Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). + +Signature: + +```typescript +export declare type AppUpdatableFields = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appupdater.md b/docs/development/core/public/kibana-plugin-public.appupdater.md new file mode 100644 index 00000000000000..f1b965cc2fc22f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdater.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdater](./kibana-plugin-public.appupdater.md) + +## AppUpdater type + +Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) + +Signature: + +```typescript +export declare type AppUpdater = (app: AppBase) => Partial | undefined; +``` diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index f03f3457ca93fb..64cbdd880fed1a 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 6ef7022f10e624..a20bc1a4e3174c 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 50a30f7c43fe68..63aeb7f711d978 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 56a7f644d34ccc..ac08baa0bb99e9 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 00000000000000..7ad26b85bf81c8 --- /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 a38499e8f37dda..fc2f8038f973fa 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 00000000000000..f097f36e999ba8 --- /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 977a65f62202d2..757c6f10f2a999 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/setup/production.asciidoc b/docs/setup/production.asciidoc index fed4ba4886bf94..eef2b11e53d85f 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/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx index 84defb4a91e3f1..84f64f99d01798 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 0ed74dd65d1ab9..6b9640d214a5ef 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "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", @@ -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", diff --git a/renovate.json5 b/renovate.json5 index 560403046b0a51..7f67fae8941104 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 b70ac610f24a79..173d73ffab664c 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -55,6 +55,7 @@ - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - [On the server side](#on-the-server-side) - [On the client side](#on-the-client-side) + - [Updates an application navlink at runtime](#updates-an-app-navlink-at-runtime) Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. @@ -1624,3 +1625,31 @@ class MyPlugin { It's not currently possible to use a similar pattern on the client-side. Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. + +### Updates an application navlink at runtime + +The application API now provides a way to updates some of a registered application's properties after registration. + +```typescript +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'Application disabled', + }) + } +``` \ No newline at end of file diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b2e2161c92cc8e..dee47315fc3222 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { @@ -25,17 +25,21 @@ import { InternalApplicationStart, ApplicationStart, InternalApplicationSetup, + App, + LegacyApp, } from './types'; import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); const createInternalSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), registerLegacyApp: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); @@ -50,8 +54,7 @@ const createInternalStartContractMock = (): jest.Mocked(); return { - availableApps: new Map(), - availableLegacyApps: new Map(), + applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), getComponent: jest.fn(), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 1132abc11703f7..4672a42c9eb060 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 5b464737ffe07e..c69b96274aa95e 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 17fec9261accf2..e7ea330657648a 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 4caf236979c373..0d955482d2226c 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -34,6 +34,9 @@ import { SavedObjectsStart } from '../saved_objects'; /** @public */ export interface AppBase { + /** + * The unique identifier of the application + */ id: string; /** @@ -41,15 +44,62 @@ export interface AppBase { */ title: string; + /** + * The initial status of the application. + * Defaulting to `accessible` + */ + status?: AppStatus; + + /** + * The initial status of the application's navLink. + * Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` + * See {@link AppNavLinkStatus} + */ + navLinkStatus?: AppNavLinkStatus; + + /** + * An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime. + * + * @example + * + * How to update an application navLink at runtime + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * private appUpdater = new BehaviorSubject(() => ({})); + * + * setup({ application }) { + * application.register({ + * id: 'my-app', + * title: 'My App', + * updater$: this.appUpdater, + * async mount(params) { + * const { renderApp } = await import('./application'); + * return renderApp(params); + * }, + * }); + * } + * + * start() { + * // later, when the navlink needs to be updated + * appUpdater.next(() => { + * navLinkStatus: AppNavLinkStatus.disabled, + * }) + * } + * ``` + */ + updater$?: Observable; + /** * An ordinal used to sort nav links relative to one another for display. */ order?: number; /** - * An observable for a tooltip shown when hovering over app link. + * A tooltip shown when hovering over app link. */ - tooltip$?: Observable; + tooltip?: string; /** * A EUI iconType that will be used for the app's icon. This icon @@ -67,8 +117,76 @@ export interface AppBase { * Custom capabilities defined by the app. */ capabilities?: Partial; + + /** + * Flag to keep track of legacy applications. + * For internal use only. any value will be overridden when registering an App. + * + * @internal + */ + legacy?: boolean; + + /** + * Hide the UI chrome when the application is mounted. Defaults to `false`. + * Takes precedence over chrome service visibility settings. + */ + chromeless?: boolean; } +/** + * Accessibility status of an application. + * + * @public + */ +export enum AppStatus { + /** + * Application is accessible. + */ + accessible = 0, + /** + * Application is not accessible. + */ + inaccessible = 1, +} + +/** + * Status of the application's navLink. + * + * @public + */ +export enum AppNavLinkStatus { + /** + * The application navLink will be `visible` if the application's {@link AppStatus} is set to `accessible` + * and `hidden` if the application status is set to `inaccessible`. + */ + default = 0, + /** + * The application navLink is visible and clickable in the navigation bar. + */ + visible = 1, + /** + * The application navLink is visible but inactive and not clickable in the navigation bar. + */ + disabled = 2, + /** + * The application navLink does not appear in the navigation bar. + */ + hidden = 3, +} + +/** + * Defines the list of fields that can be updated via an {@link AppUpdater}. + * @public + */ +export type AppUpdatableFields = Pick; + +/** + * Updater for applications. + * see {@link ApplicationSetup} + * @public + */ +export type AppUpdater = (app: AppBase) => Partial | undefined; + /** * Extension of {@link AppBase | common app properties} with the mount function. * @public @@ -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 d9c35b20db03bb..abd04722a49f20 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -18,7 +18,7 @@ */ import * as Rx from 'rxjs'; -import { toArray } from 'rxjs/operators'; +import { take, toArray } from 'rxjs/operators'; import { shallow } from 'enzyme'; import React from 'react'; @@ -54,7 +54,9 @@ function defaultStartDeps(availableApps?: App[]) { }; if (availableApps) { - deps.application.availableApps = new Map(availableApps.map(app => [app.id, app])); + deps.application.applications$ = new Rx.BehaviorSubject>( + new Map(availableApps.map(app => [app.id, app])) + ); } return deps; @@ -211,13 +213,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, navigateToApp } = startDeps.application; + const { applications$, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); + const availableApps = await applications$.pipe(take(1)).toPromise(); [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 18c0c9870d72fd..a674b49a8e1344 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { BehaviorSubject, Observable, ReplaySubject, combineLatest, of, merge } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { i18n } from '@kbn/i18n'; @@ -118,11 +118,12 @@ export class ChromeService { // combineLatest below regardless of having an application value yet. of(isEmbedded), application.currentAppId$.pipe( - map( - appId => - !!appId && - application.availableApps.has(appId) && - !!application.availableApps.get(appId)!.chromeless + flatMap(appId => + application.applications$.pipe( + map(applications => { + return !!appId && applications.has(appId) && !!applications.get(appId)!.chromeless; + }) + ) ) ) ); diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 5a45491df28e7b..3d9a4bfdb6a56c 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -20,34 +20,47 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App, LegacyApp } from '../../application'; +import { BehaviorSubject } from 'rxjs'; -const mockAppService = { - availableApps: new Map( - ([ - { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }, - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }, - ] as App[]).map(app => [app.id, app]) - ), - availableLegacyApps: new Map( - ([ - { id: 'legacyApp1', order: 5, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, - { - id: 'legacyApp2', - order: -5, - title: 'Legacy App 2', - euiIconType: 'canvasApp', - appUrl: '/app2', - }, - { id: 'legacyApp3', order: 15, title: 'Legacy App 3', appUrl: '/app3' }, - ] as LegacyApp[]).map(app => [app.id, app]) - ), -} as any; +const availableApps = new Map([ + ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], + ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], + [ + 'legacyApp1', + { + id: 'legacyApp1', + order: 5, + title: 'Legacy App 1', + icon: 'legacyApp1', + appUrl: '/app1', + legacy: true, + }, + ], + [ + 'legacyApp2', + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + legacy: true, + }, + ], + [ + 'legacyApp3', + { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3', legacy: true }, + ], +]); const mockHttp = { basePath: { @@ -57,10 +70,16 @@ const mockHttp = { describe('NavLinksService', () => { let service: NavLinksService; + let mockAppService: any; let start: ReturnType; beforeEach(() => { service = new NavLinksService(); + mockAppService = { + applications$: new BehaviorSubject>( + availableApps as any + ), + }; start = service.start({ application: mockAppService, http: mockHttp }); }); @@ -183,22 +202,36 @@ describe('NavLinksService', () => { .toPromise() ).toEqual(['legacyApp1']); }); + + it('still removes all other links when availableApps are re-emitted', async () => { + start.showOnly('legacyApp2'); + mockAppService.applications$.next(mockAppService.applications$.value); + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.map(l => l.id)) + ) + .toPromise() + ).toEqual(['legacyApp2']); + }); }); describe('#update()', () => { it('updates the navlinks and returns the updated link', async () => { - expect(start.update('legacyApp1', { hidden: true })).toMatchInlineSnapshot(` - Object { - "appUrl": "/app1", - "baseUrl": "http://localhost/wow/app1", - "hidden": true, - "icon": "legacyApp1", - "id": "legacyApp1", - "legacy": true, - "order": 5, - "title": "Legacy App 1", - } - `); + expect(start.update('legacyApp1', { hidden: true })).toEqual( + expect.objectContaining({ + appUrl: '/app1', + disabled: false, + hidden: true, + icon: 'legacyApp1', + id: 'legacyApp1', + legacy: true, + order: 5, + title: 'Legacy App 1', + }) + ); const hiddenLinkIds = await start .getNavLinks$() .pipe( @@ -212,6 +245,19 @@ describe('NavLinksService', () => { it('returns undefined if link does not exist', () => { expect(start.update('fake', { hidden: true })).toBeUndefined(); }); + + it('keeps the updated link when availableApps are re-emitted', async () => { + start.update('legacyApp1', { hidden: true }); + mockAppService.applications$.next(mockAppService.applications$.value); + const hiddenLinkIds = await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.filter(l => l.hidden).map(l => l.id)) + ) + .toPromise(); + expect(hiddenLinkIds).toEqual(['legacyApp1']); + }); }); describe('#enableForcedAppSwitcherNavigation()', () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 31a729f90cd932..650ef77b6fe42e 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -18,11 +18,13 @@ */ import { sortBy } from 'lodash'; -import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { NavLinkWrapper, ChromeNavLinkUpdateableFields, ChromeNavLink } from './nav_link'; + import { InternalApplicationStart } from '../../application'; import { HttpStart } from '../../http'; +import { ChromeNavLink, ChromeNavLinkUpdateableFields, NavLinkWrapper } from './nav_link'; +import { toNavLink } from './to_nav_link'; interface StartDeps { application: InternalApplicationStart; @@ -95,39 +97,38 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } +type LinksUpdater = (navLinks: Map) => Map; + export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { - const appLinks = [...application.availableApps] - .filter(([, app]) => !app.chromeless) - .map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: false, - baseUrl: relativeToAbsolute(http.basePath.prepend(`/app/${appId}`)), - }), - ] as [string, NavLinkWrapper] - ); - - const legacyAppLinks = [...application.availableLegacyApps].map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: true, - baseUrl: relativeToAbsolute(http.basePath.prepend(app.appUrl)), - }), - ] as [string, NavLinkWrapper] + const appLinks$ = application.applications$.pipe( + map(apps => { + return new Map( + [...apps] + .filter(([, app]) => !app.chromeless) + .map(([appId, app]) => [appId, toNavLink(app, http.basePath)]) + ); + }) ); - const navLinks$ = new BehaviorSubject>( - new Map([...legacyAppLinks, ...appLinks]) - ); + // now that availableApps$ is an observable, we need to keep record of all + // manual link modifications to be able to re-apply then after every + // availableApps$ changes. + const linkUpdaters$ = new BehaviorSubject([]); + const navLinks$ = new BehaviorSubject>(new Map()); + + combineLatest([appLinks$, linkUpdaters$]) + .pipe( + map(([appLinks, linkUpdaters]) => { + return linkUpdaters.reduce((links, updater) => updater(links), appLinks); + }) + ) + .subscribe(navlinks => { + navLinks$.next(navlinks); + }); + const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { @@ -153,7 +154,10 @@ export class NavLinksService { return; } - navLinks$.next(new Map([...navLinks$.value.entries()].filter(([linkId]) => linkId === id))); + const updater: LinksUpdater = navLinks => + new Map([...navLinks.entries()].filter(([linkId]) => linkId === id)); + + linkUpdaters$.next([...linkUpdaters$.value, updater]); }, update(id: string, values: ChromeNavLinkUpdateableFields) { @@ -161,17 +165,17 @@ export class NavLinksService { return; } - navLinks$.next( + const updater: LinksUpdater = navLinks => new Map( - [...navLinks$.value.entries()].map(([linkId, link]) => { + [...navLinks.entries()].map(([linkId, link]) => { return [linkId, link.id === id ? link.update(values) : link] as [ string, NavLinkWrapper ]; }) - ) - ); + ); + linkUpdaters$.next([...linkUpdaters$.value, updater]); return this.get(id); }, @@ -196,10 +200,3 @@ function sortNavLinks(navLinks: ReadonlyMap) { 'order' ); } - -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts new file mode 100644 index 00000000000000..23fdabe0f34301 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppMount, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { toNavLink } from './to_nav_link'; + +import { httpServiceMock } from '../../mocks'; + +function mount() {} + +const app = (props: Partial = {}): App => ({ + mount: (mount as unknown) as AppMount, + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + appRoute: `/app/some-id`, + legacy: false, + ...props, +}); + +const legacyApp = (props: Partial = {}): LegacyApp => ({ + appUrl: '/my-app-url', + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + legacy: true, + ...props, +}); + +describe('toNavLink', () => { + const basePath = httpServiceMock.createSetupContract({ basePath: '/base-path' }).basePath; + + it('uses the application properties when creating the navLink', () => { + const link = toNavLink( + app({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }), + basePath + ); + expect(link.properties).toEqual( + expect.objectContaining({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }) + ); + }); + + it('flags legacy apps when converting to navLink', () => { + expect(toNavLink(app({}), basePath).properties.legacy).toEqual(false); + expect(toNavLink(legacyApp({}), basePath).properties.legacy).toEqual(true); + }); + + it('handles applications with custom app route', () => { + const link = toNavLink( + app({ + appRoute: '/my-route/my-path', + }), + basePath + ); + expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path'); + }); + + it('uses appUrl when converting legacy applications', () => { + expect( + toNavLink( + legacyApp({ + appUrl: '/my-legacy-app/#foo', + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + baseUrl: 'http://localhost/base-path/my-legacy-app/#foo', + }) + ); + }); + + it('uses the application status when the navLinkStatus is set to default', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + }); + + it('uses the navLinkStatus of the application to set the hidden and disabled properties', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.visible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.hidden, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.disabled, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: true, + hidden: false, + }) + ); + }); +}); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts new file mode 100644 index 00000000000000..18e4b7b26b6ba1 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { IBasePath } from '../../http'; +import { NavLinkWrapper } from './nav_link'; + +export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { + const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; + return new NavLinkWrapper({ + ...app, + hidden: useAppStatus + ? app.status === AppStatus.inaccessible + : app.navLinkStatus === AppNavLinkStatus.hidden, + disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, + legacy: isLegacyApp(app), + baseUrl: isLegacyApp(app) + ? relativeToAbsolute(basePath.prepend(app.appUrl)) + : relativeToAbsolute(basePath.prepend(app.appRoute!)), + }); +} + +function relativeToAbsolute(url: string) { + // convert all link urls to absolute urls + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} + +function isLegacyApp(app: App | LegacyApp): app is LegacyApp { + return app.legacy === true; +} diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 75f78ac8b2fa00..0447add4917882 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -309,7 +309,7 @@ class HeaderUI extends Component { .filter(navLink => !navLink.hidden) .map(navLink => ({ key: navLink.id, - label: navLink.title, + label: navLink.tooltip ?? navLink.title, // Use href and onClick to support "open in new tab" and SPA navigation in the same link href: navLink.href, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 44dc76bfe6e320..36b220f16f3950 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 ea704749c61315..5b17eccc37f8b8 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 a4fdd86de53112..f906aff1759e2e 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -81,6 +81,7 @@ export class LegacyPlatformService { ...core, getStartServices: () => this.startDependencies, application: { + ...core.application, register: notSupported(`core.application.register()`), registerMountContext: notSupported(`core.application.registerMountContext()`), }, diff --git a/src/core/public/notifications/toasts/global_toast_list.test.tsx b/src/core/public/notifications/toasts/global_toast_list.test.tsx index 61d73ac2331886..dc2a9dabe791e9 100644 --- a/src/core/public/notifications/toasts/global_toast_list.test.tsx +++ b/src/core/public/notifications/toasts/global_toast_list.test.tsx @@ -57,9 +57,9 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { it('passes latest value from toasts$ to ', () => { const el = shallow( render({ - toasts$: Rx.from([[], [{ id: 1 }], [{ id: 1 }, { id: 2 }]]) as any, + toasts$: Rx.from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, }) ); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: 1 }, { id: 2 }]); + expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); }); diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 848f46605d4deb..f146c2452868b4 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -96,6 +96,7 @@ export function createPluginSetupContext< return { application: { register: app => deps.application.register(plugin.opaqueId, app), + registerAppUpdater: statusUpdater$ => deps.application.registerAppUpdater(statusUpdater$), registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c76d6191de8a39..aef689162f45a2 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/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 1b52f22c4da09a..a4e51ca55b3e71 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 9f694ac1c46da2..5a7d223fec7ad9 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 db3fda3a504ab5..aba246ce66fb50 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 22340bf3f2fc62..899b273c5c60ad 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 3982df567ed7ce..6fa3357168027d 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 f3867faa2ae75f..65c4f1432721de 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/mocks.ts b/src/core/server/mocks.ts index 073d380d3aa67b..c7082d46313ae3 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 bf7dc14c732659..7f3a9605710127 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 611842e8a7de0d..7c3f9f249db137 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/ui_settings/types.ts b/src/core/server/ui_settings/types.ts index 5e3f0a4fbb6bd2..14eb71a22cefcc 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 aa98f510674116..c857e980dec21a 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/sass/build_sass.js b/src/dev/sass/build_sass.js index 14f03a7a116a6f..1ff7c700d03861 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 40b9cc4640eef1..761a252b56a877 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 4ecd5d415833cc..1adc56d47927be 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 608c73335b3e53..6262c304e307bf 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,30 @@ 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; + 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 b88e0e44591d83..7c4d871c4d73e2 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 88467ab3672cd7..7de874c244e74f 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 a7f59acf1d77b6..0b10938abe7042 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/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 00000000000000..16773c02f5a7b1 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts @@ -0,0 +1,117 @@ +/* + * 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://notDashboardUrl'; + expect(getUrlVars(url)).toEqual({}); + url = 'http://localhost:5601/app/kibana#/dashboard/777182'; + expect(getUrlVars(url)).toEqual({}); + }); + + 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 ec0913e5fb3e77..ba019194310807 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/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index 2523d1e60a7412..2706b588a2ec4f 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 b76b3f309874ab..fe42e079127999 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 b2f004568841a3..2a9a793ba43c4a 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 827fe6eabe7849..30bf940069fb7b 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/url_helper.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts new file mode 100644 index 00000000000000..ee9e3c4ef4781e --- /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] = 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/home/np_ready/components/tutorial/tutorial.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js index 314ddf2196f06c..c7aa5b0f5b2f97 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 06da6f35ee42e5..697c1b0468cd14 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 00000000000000..100a8b6ae367c4 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 00000000000000..ad0cb64b161dde --- /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/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 10d165d0d69c46..eef8f3fc93d90f 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/field.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js index 939dc8c20e4657..a2f201cf757f52 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -19,12 +19,14 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; +import { npStart } from 'ui/new_platform'; import 'brace/theme/textmate'; import 'brace/mode/markdown'; import { toastNotifications } from 'ui/notify'; import { + EuiBadge, EuiButton, EuiButtonEmpty, EuiCode, @@ -41,6 +43,7 @@ import { EuiImage, EuiLink, EuiSpacer, + EuiToolTip, EuiText, EuiSelect, EuiSwitch, @@ -224,7 +227,7 @@ export class Field extends PureComponent { } const file = files[0]; - const { maxSize } = this.props.setting.options; + const { maxSize } = this.props.setting.validation; try { const base64Image = await this.getImageAsBase64(file); const isInvalid = !!(maxSize && maxSize.length && base64Image.length > 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 74bb0e25ff52eb..07ce6f84d2bb64 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 791f9e400b4075..6efb89cfba2b25 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/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index ed9bec9db41126..64653730473cdd 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 840e647edcc86d..b770625cd3d700 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/new_vis_modal.test.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx index 2005133e6d03e4..0ef1b711eafc82 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 9e8f46407f5918..082fc3bc36b6b5 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; + +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 06a974f473498e..b5b53b1daba553 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/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 7ab7d7653eb5e3..4ec29ca409b80b 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 6f5f9b39561876..15e74e98920e29 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", }, @@ -1504,6 +1510,9 @@ exports[`QueryStringInput Should pass the query language to the language switche "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", }, @@ -2121,6 +2130,9 @@ exports[`QueryStringInput Should pass the query language to the language switche "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", }, @@ -2729,6 +2741,9 @@ exports[`QueryStringInput Should render the given query 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", }, @@ -3346,6 +3361,9 @@ exports[`QueryStringInput Should render the given query 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/kibana_react/public/saved_objects/saved_object_finder.tsx b/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx index bd2beaf77a3059..1522c6b42824c5 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 2e7b22a14fb0eb..4c2dac4f39134c 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 5c50e152ad46ca..b905aeff41f1ff 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 643763cc4cee93..4ddf532c1506db 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 6d0c960e2a5b26..e807783a56f319 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 657b64f55a7766..2c426cae6733a4 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 3b7a8b8bd4621d..583f8f65ce6b69 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 66d28bbd8603f0..f8ded75ed3f45c 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 7a15483d83b44f..a72995f4f1eaed 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 363fd9253d44fd..1bab1af1d5f688 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 95f4c35f2ce01b..d4877acaa5ca00 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 b949a9daed0ae6..d420aec30f068c 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 c1a35441b637b4..0f25f65c30ade0 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 45b34b13251f44..36903f2d7c90f7 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 e120f60e72b8f3..5f27a3d2c1dcad 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 cc513bc674d0fc..08ad1551420d2e 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 f0ef1423dec71b..9c1116e5da5318 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 0f7395ad0f0e56..3009c1d161a532 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/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 38ee5b7db39c47..e25d2955159710 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 7adfe7ebfcc7d7..72440b648e5388 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/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 3ba0f217813f2c..85d8cff675f2d0 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/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 1562cf9745f2de..4ba64ea771effe 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -322,6 +322,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 a4cd98b2a06ec1..fe17532f6a41ac 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -25,7 +25,7 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return new (class AppsMenu { /** - * Get the text and href from each of the links in the apps menu + * Get the attributes from each of the links in the apps menu */ public async readLinks() { const appMenu = await testSubjects.find('navDrawer'); @@ -37,12 +37,21 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return { text: $(link).text(), href: $(link).attr('href'), + disabled: $(link).attr('disabled') != null, }; }); return links; } + /** + * Get the attributes from the link with the given name. + * @param name + */ + public async getLink(name: string) { + return (await this.readLinks()).find(nl => nl.text === name); + } + /** * Determine if an app link with the given name exists * @param name diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json new file mode 100644 index 00000000000000..91d8e6fd8f9e18 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_app_status", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_app_status"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_app_status/package.json b/test/plugin_functional/plugins/core_app_status/package.json new file mode 100644 index 00000000000000..61655487c6acbc --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_app_status", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_app_status", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_app_status/public/application.tsx b/test/plugin_functional/plugins/core_app_status/public/application.tsx new file mode 100644 index 00000000000000..323774392a6d73 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/application.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const AppStatusApp = () => ( + + + + + +

Welcome to App Status Test App!

+
+
+
+ + + + +

App Status Test App home page section title

+
+
+
+ App Status Test App content +
+
+
+); + +export const renderApp = (context: AppMountContext, { element }: AppMountParameters) => { + render(, element); + + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_app_status/public/index.ts b/test/plugin_functional/plugins/core_app_status/public/index.ts new file mode 100644 index 00000000000000..e0ad7c25a54b81 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreAppStatusPlugin, CoreAppStatusPluginSetup, CoreAppStatusPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppStatusPlugin(); diff --git a/test/plugin_functional/plugins/core_app_status/public/plugin.tsx b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx new file mode 100644 index 00000000000000..85caaaf5f9090f --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup, AppUpdater, AppUpdatableFields, CoreStart } from 'kibana/public'; +import { BehaviorSubject } from 'rxjs'; + +export class CoreAppStatusPlugin + implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'app_status', + title: 'App Status', + euiIconType: 'snowflake', + updater$: this.appUpdater, + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return { + setAppStatus: (status: Partial) => { + this.appUpdater.next(() => status); + }, + navigateToApp: async (appId: string) => { + return core.application.navigateToApp(appId); + }, + }; + } + public stop() {} +} + +export type CoreAppStatusPluginSetup = ReturnType; +export type CoreAppStatusPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json new file mode 100644 index 00000000000000..5fcaeafbb0d852 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts new file mode 100644 index 00000000000000..703ae30533bae0 --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, +} from '../../../../src/core/public/application/types'; +import { PluginFunctionalProviderContext } from '../../services'; +import { CoreAppStatusPluginStart } from '../../plugins/core_app_status/public/plugin'; +import '../../plugins/core_provider_plugin/types'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + + const setAppStatus = async (s: Partial) => { + await browser.executeAsync(async (status: Partial, cb: Function) => { + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + plugin.setAppStatus(status); + cb(); + }, s); + }; + + const navigateToApp = async (i: string): Promise<{ error?: string }> => { + return (await browser.executeAsync(async (appId, cb: Function) => { + // navigating in legacy mode performs a page refresh + // and webdriver seems to re-execute the script after the reload + // as it considers it didn't end on the previous session. + // however when testing navigation to NP app, __coreProvider is not accessible + // so we need to check for existence. + if (!window.__coreProvider) { + cb({}); + } + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + try { + await plugin.navigateToApp(appId); + cb({}); + } catch (e) { + cb({ + error: e.message, + }); + } + }, i)) as any; + }; + + describe('application status management', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('settings'); + }); + + it('can change the navLink status at runtime', async () => { + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.disabled, + }); + let link = await appsMenu.getLink('App Status'); + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(true); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.hidden, + }); + link = await appsMenu.getLink('App Status'); + expect(link).to.eql(undefined); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.visible, + tooltip: 'Some tooltip', + }); + link = await appsMenu.getLink('Some tooltip'); // the tooltip replaces the name in the selector we use. + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(false); + }); + + it('shows an error when navigating to an inaccessible app', async () => { + await setAppStatus({ + status: AppStatus.inaccessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.contain( + 'Trying to navigate to an inaccessible application: app_status' + ); + }); + + it('allows to navigate to an accessible app', async () => { + await setAppStatus({ + status: AppStatus.accessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.eql(undefined); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 6c55245d10f03c..d66e2e7dc5da73 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/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts index 2f15ae1c0a2b36..63f1b545179c73 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 f66d1947c2b8b9..351c1add7b4513 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 9e75248c56caee..dfbd2db4b68423 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/index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts index 3a0c9f415cc2ba..5fcf39c2e8fdd0 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/create_execute_function.test.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts index 6de446ee2da767..7dbcfce5ee3359 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 8ff12b8c3fa4b6..ddd8b1df2327b2 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 5eab3418467bce..6f221b08c4bc55 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 5b60696c42d523..ad2b74da0d7d44 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 ca6a726f40e148..2dc3d1161399e6 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 48f99ba5135b78..ffc4a9cf90e54c 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 f8aa9b8d7a25ce..8077dc67c92c4a 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 8e96ad8dae31cc..e1a05d6460e254 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 2003e810a05b53..1e9007202c4521 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 32293d9755a2a7..2af66059d9fed2 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 33a6b716e9b8a1..fe96a233b8663f 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 519001d07e089c..754e02a3f1e5ec 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 94a396fbaa8063..eab1cc3ce627b4 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 644ae51292249d..52843f63623032 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 fb16f579d4c701..357db9e3df97ed 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 ae29048d83dd9e..ccc10f929e123f 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 87fa33a9cea587..394c13e1bd24f6 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 42c332e82e0343..0f643e3d3121cc 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 2ea1256352bec9..543b9e7d32e12a 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 7186e1e729bda4..7178fa4f01282c 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 e345ca3552e5ae..8f87b3473b2e48 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 0d7ff3114e73fb..ce2db4964a4120 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 00000000000000..fbaa489c450399 --- /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 cf2cbd25072151..0934cb0019f44c 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 238158c5bf224f..bc020815cc9cbb 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 @@ -73,6 +73,7 @@ export function Cytoscape({ cy.on('data', event => { // 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'); } 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 00000000000000..efafdbcecd41cd --- /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/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 03ae9d0c287e53..d4e792ccf761bc 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 @@ -8,17 +8,13 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { icons, defaultIcon } 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'; + return !agentName; } const style: cytoscape.Stylesheet[] = [ @@ -47,7 +43,7 @@ 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) => @@ -76,7 +72,18 @@ 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 } } ]; 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 00000000000000..c9caa27af41c50 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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'] + }; + } + + 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/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index cc09975a344b5e..d3cc2b14e2c685 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 @@ -5,13 +5,30 @@ */ import theme from '@elastic/eui/dist/eui_theme_light.json'; -import React from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; +import React, { + useMemo, + useEffect, + useState, + useRef, + useCallback +} from 'react'; +import { find, isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { ElementDefinition } from 'cytoscape'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; import { useLicense } from '../../../hooks/useLicense'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; +import { useCallApmApi } from '../../../hooks/useCallApmApi'; +import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; +import { useLocation } from '../../../hooks/useLocation'; +import { LoadingOverlay } from './LoadingOverlay'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { getCytoscapeElements } from './get_cytoscape_elements'; interface ServiceMapProps { serviceName?: string; @@ -37,37 +54,159 @@ ${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/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts index aeeb39733b5db3..737eeac95516e9 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/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 00000000000000..04e2a43a4b8f1d --- /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 00000000000000..ea9af12ac7f9a4 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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['span.type'] == 'external' || event['span.type'] == 'messaging') { + 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_trace_sample_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts new file mode 100644 index 00000000000000..acf113b4266081 --- /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/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts index e98842151da847..a9a8241da39d17 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 } from './service_map'; const createApmApi = () => { const api = createApi() @@ -118,10 +118,12 @@ const createApmApi = () => { .add(transactionsLocalFiltersRoute) .add(serviceNodesLocalFiltersRoute) .add(uiFiltersEnvironmentsRoute) - .add(serviceMapRoute) // Transaction - .add(transactionByTraceIdRoute); + .add(transactionByTraceIdRoute) + + // Service map + .add(serviceMapRoute); 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 00000000000000..94b176147f7a1d --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/service_map.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * 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'; + +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 }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 78cb092b85db66..18777183ea1de3 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 74a9436d7a4bc6..6d3620f11a87b2 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 eff39838bd957e..064b684cf9aa6b 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/canvas/canvas_plugin_src/expression_types/embeddable.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts index 063e69d1d21416..e728ea25f55043 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 3669bd3e082016..8f5ad859d28ba9 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 097aef69d4b4c5..48b50930d563e0 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 00000000000000..21f9e9fe3148d9 --- /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 25f035bbb6d8c3..5b95886faa13da 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 460cb9c34efffc..b6d88c06ed06d0 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 00000000000000..716026279ccea2 --- /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 5c7ef1a8c1799c..8642ebd901bb4d 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 00000000000000..93d747537c34c1 --- /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 00000000000000..a3cb53acebed24 --- /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 50fa6943fc74ae..48364be06e539c 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/i18n/functions/dict/map_center.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts new file mode 100644 index 00000000000000..3022ad07089d25 --- /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 d01b77e1cfd511..53bcd481f185ff 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 00000000000000..476a9978800df9 --- /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 f6b3c451c6fbbc..94d7e6f43326f4 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 261f67067cfaf0..5ab6a908641dea 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/element_content/element_content.js b/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js index 89c0b5b21c581a..1926fb4aaa5eb8 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js +++ b/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js @@ -47,7 +47,14 @@ export const ElementContent = compose( pure, ...branches )(({ renderable, renderFunction, size, handlers }) => { - const { getFilter, setFilter, done, onComplete } = handlers; + const { + getFilter, + setFilter, + done, + onComplete, + onEmbeddableInputChange, + onEmbeddableDestroyed, + } = handlers; return Style.it( renderable.css, @@ -69,7 +76,7 @@ export const ElementContent = compose( config={renderable.value} css={renderable.css} // This is an actual CSS stylesheet string, it will be scoped by RenderElement size={size} // Size is only passed for the purpose of triggering the resize event, it isn't really used otherwise - handlers={{ getFilter, setFilter, done }} + handlers={{ getFilter, setFilter, done, onEmbeddableInputChange, onEmbeddableDestroyed }} /> diff --git a/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js b/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js index ce6791f2f88b63..e93cea597901f9 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js +++ b/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js @@ -6,6 +6,10 @@ import { isEqual } from 'lodash'; import { setFilter } from '../../../state/actions/elements'; +import { + updateEmbeddableExpression, + fetchEmbeddableRenderable, +} from '../../../state/actions/embeddable'; export const createHandlers = dispatch => { let isComplete = false; @@ -32,6 +36,14 @@ export const createHandlers = dispatch => { completeFn = fn; }, + onEmbeddableInputChange(embeddableExpression) { + dispatch(updateEmbeddableExpression({ elementId: element.id, embeddableExpression })); + }, + + onEmbeddableDestroyed() { + dispatch(fetchEmbeddableRenderable(element.id)); + }, + done() { // don't emit if the element is already done if (isComplete) { diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index c54c56e1561cae..565ca5fa5bbd6e 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -19,14 +19,15 @@ import { withKibana } from '../../../../../../../src/plugins/kibana_react/public const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { - return `filters | savedMap id="${id}" | render`; + return `savedMap id="${id}" | render`; }, - [EmbeddableTypes.visualization]: (id: string) => { + // FIX: Only currently allow Map embeddables + /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; }, [EmbeddableTypes.search]: (id: string) => { return `filters | savedSearch id="${id}" | render`; - }, + },*/ }; interface StateProps { diff --git a/x-pack/legacy/plugins/canvas/public/components/expression/index.js b/x-pack/legacy/plugins/canvas/public/components/expression/index.js index 806ef388bc4f6f..d6eefca4e14614 100644 --- a/x-pack/legacy/plugins/canvas/public/components/expression/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/expression/index.js @@ -55,6 +55,17 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { }; const expressionLifecycle = lifecycle({ + componentDidUpdate({ expression }) { + if ( + this.props.expression !== expression && + this.props.expression !== this.props.formState.expression + ) { + this.props.setFormState({ + expression: this.props.expression, + dirty: false, + }); + } + }, componentDidMount() { const { functionDefinitionsPromise, setFunctionDefinitions } = this.props; functionDefinitionsPromise.then(defs => setFunctionDefinitions(defs)); 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 da3475eceb18d9..089f021ccdc325 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_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index 4ee3a65172a2ed..b775524acf6393 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 cf07d1ed229f0e..139d0f283bf1a1 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/state/actions/embeddable.ts b/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts new file mode 100644 index 00000000000000..3604d7e3c2141d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch } from 'redux'; +import { createAction } from 'redux-actions'; +// @ts-ignore Untyped +import { createThunk } from 'redux-thunks'; +// @ts-ignore Untyped Local +import { fetchRenderable } from './elements'; +import { State } from '../../../types'; + +export const UpdateEmbeddableExpressionActionType = 'updateEmbeddableExpression'; +export interface UpdateEmbeddableExpressionPayload { + embeddableExpression: string; + elementId: string; +} +export const updateEmbeddableExpression = createAction( + UpdateEmbeddableExpressionActionType +); + +export const fetchEmbeddableRenderable = createThunk( + 'fetchEmbeddableRenderable', + ({ dispatch, getState }: { dispatch: Dispatch; getState: () => State }, elementId: string) => { + const pageWithElement = getState().persistent.workpad.pages.find(page => { + return page.elements.find(element => element.id === elementId) !== undefined; + }); + + if (pageWithElement) { + const element = pageWithElement.elements.find(el => el.id === elementId); + dispatch(fetchRenderable(element)); + } + } +); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js index 10a5bdb5998ea1..c7e8a5c2ff2d81 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js @@ -28,7 +28,7 @@ function getNodeIndexById(page, nodeId, location) { return page[location].findIndex(node => node.id === nodeId); } -function assignNodeProperties(workpadState, pageId, nodeId, props) { +export function assignNodeProperties(workpadState, pageId, nodeId, props) { const pageIndex = getPageIndexById(workpadState, pageId); const location = getLocationFromIds(workpadState, pageId, nodeId); const nodesPath = `pages.${pageIndex}.${location}`; diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts new file mode 100644 index 00000000000000..9969c38cfa7672 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.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 { fromExpression, toExpression } from '@kbn/interpreter/common'; +import { handleActions } from 'redux-actions'; +import { State } from '../../../types'; + +import { + UpdateEmbeddableExpressionActionType, + UpdateEmbeddableExpressionPayload, +} from '../actions/embeddable'; + +// @ts-ignore untyped local +import { assignNodeProperties } from './elements'; + +export const embeddableReducer = handleActions< + State['persistent']['workpad'], + UpdateEmbeddableExpressionPayload +>( + { + [UpdateEmbeddableExpressionActionType]: (workpadState, { payload }) => { + if (!payload) { + return workpadState; + } + + const { elementId, embeddableExpression } = payload; + + // Find the element + const pageWithElement = workpadState.pages.find(page => { + return page.elements.find(element => element.id === elementId) !== undefined; + }); + + if (!pageWithElement) { + return workpadState; + } + + const element = pageWithElement.elements.find(elem => elem.id === elementId); + + if (!element) { + return workpadState; + } + + const existingAst = fromExpression(element.expression); + const newAst = fromExpression(embeddableExpression); + const searchForFunction = newAst.chain[0].function; + + // Find the first matching function in the existing ASt + const existingAstFunction = existingAst.chain.find(f => f.function === searchForFunction); + + if (!existingAstFunction) { + return workpadState; + } + + existingAstFunction.arguments = newAst.chain[0].arguments; + + const updatedExpression = toExpression(existingAst); + + return assignNodeProperties(workpadState, pageWithElement.id, elementId, { + expression: updatedExpression, + }); + }, + }, + {} as State['persistent']['workpad'] +); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts new file mode 100644 index 00000000000000..5b1192630897a2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +jest.mock('ui/new_platform'); +import { State } from '../../../types'; +import { updateEmbeddableExpression } from '../actions/embeddable'; +import { embeddableReducer } from './embeddable'; + +const elementId = 'element-1111'; +const embeddableId = '1234'; +const mockWorkpadState = { + pages: [ + { + elements: [ + { + id: elementId, + expression: `function1 | function2 id="${embeddableId}" change="start value" remove="remove"`, + }, + ], + }, + ], +} as State['persistent']['workpad']; + +describe('embeddables reducer', () => { + it('updates the functions expression', () => { + const updatedValue = 'updated value'; + + const action = updateEmbeddableExpression({ + elementId, + embeddableExpression: `function2 id="${embeddableId}" change="${updatedValue}" add="add"`, + }); + + const newState = embeddableReducer(mockWorkpadState, action); + + expect(newState.pages[0].elements[0].expression.replace(/\s/g, '')).toBe( + `function1 | ${action.payload!.embeddableExpression}`.replace(/\s/g, '') + ); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/index.js b/x-pack/legacy/plugins/canvas/public/state/reducers/index.js index b60a0a3b32656d..cec6f9dceef6d5 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/index.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/index.js @@ -16,6 +16,7 @@ import { pagesReducer } from './pages'; import { elementsReducer } from './elements'; import { assetsReducer } from './assets'; import { historyReducer } from './history'; +import { embeddableReducer } from './embeddable'; export function getRootReducer(initialState) { return combineReducers({ @@ -25,7 +26,7 @@ export function getRootReducer(initialState) { persistent: reduceReducers( historyReducer, combineReducers({ - workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer), + workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer, embeddableReducer), schemaVersion: (state = get(initialState, 'persistent.schemaVersion')) => state, }) ), diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts index d1632fc3eef285..b422a9451293ff 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts +++ b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts @@ -23,10 +23,10 @@ const timeFilter: Filter = { }; describe('buildEmbeddableFilters', () => { - it('converts non time Canvas Filters to ES Filters ', () => { + it('converts all Canvas Filters to ES Filters ', () => { const filters = buildEmbeddableFilters([timeFilter, columnFilter, columnFilter]); - expect(filters.filters).toHaveLength(2); + expect(filters.filters).toHaveLength(3); }); it('converts time filter to time range', () => { diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts index 52fcc9813a93db..1a78a1e0570164 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts @@ -35,10 +35,8 @@ function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { : undefined; } -function getQueryFilters(filters: Filter[]): esFilters.Filter[] { - return buildBoolArray(filters.filter(filter => filter.type !== 'time')).map( - esFilters.buildQueryFilter - ); +export function getQueryFilters(filters: Filter[]): esFilters.Filter[] { + return buildBoolArray(filters).map(esFilters.buildQueryFilter); } export function buildEmbeddableFilters(filters: Filter[]): EmbeddableFilterInput { diff --git a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js b/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js index 15d3dc52ee3117..f7907e2cffb261 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js +++ b/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js @@ -30,6 +30,19 @@ export const queryEsSQL = (elasticsearchClient, { count, query, filter, timezone }); const columnNames = map(columns, 'name'); const rows = res.rows.map(row => zipObject(columnNames, row)); + + if (!!res.cursor) { + elasticsearchClient('transport.request', { + path: '/_sql/close', + method: 'POST', + body: { + cursor: res.cursor, + }, + }).catch(e => { + throw new Error(`Unexpected error from Elasticsearch: ${e.message}`); + }); + } + return { type: 'datatable', columns, diff --git a/x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js b/x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js deleted file mode 100644 index 36f7399ecd031d..00000000000000 --- a/x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js +++ /dev/null @@ -1,33 +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 { mapValues, keys } from 'lodash'; -import { normalizeType } from '../../lib/normalize_type'; - -export function getESFieldTypes(index, fields, elasticsearchClient) { - const config = { - index: index, - fields: fields || '*', - }; - - if (fields && fields.length === 0) { - return Promise.resolve({}); - } - - return elasticsearchClient('fieldCaps', config).then(resp => { - return mapValues(resp.fields, types => { - if (keys(types).length > 1) { - return 'conflict'; - } - - try { - return normalizeType(keys(types)[0]); - } catch (e) { - return 'unsupported'; - } - }); - }); -} diff --git a/x-pack/legacy/plugins/canvas/server/routes/es_fields/index.ts b/x-pack/legacy/plugins/canvas/server/routes/es_fields/index.ts deleted file mode 100644 index 6c1dd723299c67..00000000000000 --- a/x-pack/legacy/plugins/canvas/server/routes/es_fields/index.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { partial } from 'lodash'; -import { API_ROUTE } from '../../../common/lib/constants'; -import { CoreSetup } from '../../shim'; -// @ts-ignore untyped local -import { getESFieldTypes } from './get_es_field_types'; - -// TODO: Error handling, note: esErrors - -interface ESFieldsRequest { - query: { - index: string; - fields: string[]; - }; -} - -export function esFields( - route: CoreSetup['http']['route'], - elasticsearch: CoreSetup['elasticsearch'] -) { - const { callWithRequest } = elasticsearch.getCluster('data'); - - route({ - method: 'GET', - path: `${API_ROUTE}/es_fields`, - handler(request: ESFieldsRequest, h: any) { - const { index, fields } = request.query; - if (!index) { - return h.response({ error: '"index" query is required' }).code(400); - } - - return getESFieldTypes(index, fields, partial(callWithRequest, request)); - }, - }); -} diff --git a/x-pack/legacy/plugins/canvas/server/routes/index.ts b/x-pack/legacy/plugins/canvas/server/routes/index.ts index 2f6b706fc7edbc..6898a3c459e3d4 100644 --- a/x-pack/legacy/plugins/canvas/server/routes/index.ts +++ b/x-pack/legacy/plugins/canvas/server/routes/index.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { esFields } from './es_fields'; import { shareableWorkpads } from './shareables'; import { CoreSetup } from '../shim'; export function routes(setup: CoreSetup): void { - esFields(setup.http.route, setup.elasticsearch); shareableWorkpads(setup.http.route); } diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx b/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx index 03b3e0df8a0cf8..317a3417841b8b 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx @@ -69,6 +69,8 @@ export class RenderedElementComponent extends PureComponent { onResize: () => {}, setFilter: () => {}, getFilter: () => '', + onEmbeddableInputChange: () => {}, + onEmbeddableDestroyed: () => {}, }); } catch (e) { // eslint-disable-next-line no-console diff --git a/x-pack/legacy/plugins/canvas/types/functions.ts b/x-pack/legacy/plugins/canvas/types/functions.ts index 6510c018f1ed4a..773c9c3020a857 100644 --- a/x-pack/legacy/plugins/canvas/types/functions.ts +++ b/x-pack/legacy/plugins/canvas/types/functions.ts @@ -192,3 +192,16 @@ export interface AxisConfig { */ export const isAxisConfig = (axisConfig: any): axisConfig is AxisConfig => !!axisConfig && axisConfig.type === 'axisConfig'; + +export interface MapCenter { + type: 'mapCenter'; + lat: number; + lon: number; + zoom: number; +} + +export interface TimeRange { + type: 'timerange'; + from: string; + to: string; +} diff --git a/x-pack/legacy/plugins/canvas/types/renderers.ts b/x-pack/legacy/plugins/canvas/types/renderers.ts index 282a1c820e3462..af1710e69c2578 100644 --- a/x-pack/legacy/plugins/canvas/types/renderers.ts +++ b/x-pack/legacy/plugins/canvas/types/renderers.ts @@ -17,6 +17,10 @@ export interface RendererHandlers { getFilter: () => string; /** Sets the value of the filter property on the element object persisted on the workpad */ setFilter: (filter: string) => void; + /** Handler to invoke when the input to a function has changed internally */ + onEmbeddableInputChange: (expression: string) => void; + /** Handler to invoke when a rendered embeddable is destroyed */ + onEmbeddableDestroyed: () => void; } export interface RendererSpec { diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss b/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss index 6fa51c1ba1ec8c..e54158e2ad8ce3 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss +++ b/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss @@ -13,6 +13,7 @@ .help-block { font-size: $euiFontSizeXS; + color: $euiTextColor; } } diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 20b1059ae45eca..4493d794cb8d12 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -1,4 +1,4 @@ -
+
@@ -81,6 +81,7 @@ @@ -386,4 +396,4 @@
- + diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 5ff7fc2e5da93d..957a8f66907a13 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/graph_title.tsx b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx new file mode 100644 index 00000000000000..8151900da0c076 --- /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 f1c332eba1aa8a..e1423b794dcd33 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 5fae9720db39a5..f34b82d6bb1a3c 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/infra/common/http_api/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts index 17494212777198..d9ca9a96ffe51e 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 00000000000000..66823c25237aca --- /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 00000000000000..934d1052fa29fc --- /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 1047ca2f2a01a6..caeb1914cb8a2c 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/ml/public/application/components/controls/select_interval/index.d.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts similarity index 58% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.d.ts rename to x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts index 4a8273972389a5..a208921c03d6fe 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.d.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BehaviorSubject } from 'rxjs'; +import * as rt from 'io-ts'; -export const interval$: BehaviorSubject<{ - value: string; - text: string; -}>; +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 79913f829191d2..22137e63ab7e72 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 4a6f20d549799e..9b2f1a55eb8c1f 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 00000000000000..1dcd4a10fc4e3b --- /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 00000000000000..0957126ee52e3b --- /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 00000000000000..7fd668dc4ebce6 --- /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 00000000000000..3e96f3c19d06d4 --- /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 297743f9b3456e..d5b858df38def1 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 8ccb051724ede5..dbdc827478a45b 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/logging/log_analysis_job_status/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index 06229a26afd192..e954cf21229ee0 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 018c5f5e0570da..8a16d819e12c2a 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 00000000000000..74e8d197ef4555 --- /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 b95054bbd6a9b0..5b872d4ee5147f 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 00000000000000..7fcdcc89a633ad --- /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 8a4ceb70252a3b..a3139124e6c9ff 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/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 41c155e185c3a5..a067285026e33e 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/log_entry_categories/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts index 5910dc54dfc902..be7547f2e74cb9 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 9a50acf622ee13..cc59d730557968 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 00000000000000..ffffba0691749d --- /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/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 00000000000000..e50231316fb5af --- /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 00000000000000..5c8b18528cae60 --- /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 00000000000000..c30612f54be00b --- /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 00000000000000..9c22caa4b34654 --- /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/explorer/select_limit/index.js b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts similarity index 85% rename from x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.js rename to x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts index fa1b24e1181805..e699bbf956f94b 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/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. */ -import './select_limit_service.js'; +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 00000000000000..7a29ea9aa0ebc8 --- /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 00000000000000..1352afb60a5057 --- /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 00000000000000..5fb8e3380f23f7 --- /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 00000000000000..0281615a59c785 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 { 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 00000000000000..3d20aef03ff158 --- /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 00000000000000..942ded4230e974 --- /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 00000000000000..35d6f1ec4f893f --- /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 00000000000000..2282582dc2bd61 --- /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 00000000000000..bf30f96e4b7419 --- /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 1ab9356a69e2a1..00000000000000 --- 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 52be3132643353..52ba3101dbc38e 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 b6ab8acdea5b20..693444c02ce5f3 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; @@ -196,7 +197,12 @@ 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/sections/anomalies/chart.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index a75e6c50ab03fe..1a3a7d9e2b572e 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 e5e719c2d69f6a..4aff907cfad66d 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'; @@ -99,9 +100,7 @@ export const AnomaliesResults: React.FunctionComponent<{ - - 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 45893315c73619..3e86b45fadfdd6 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 f9b85fc4e20c23..e8e4c18e7420c4 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/utils/use_tracked_promise.ts b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts index c23bab7026aaad..e9a966b97e4dd7 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 f99589e1b52bd7..4f290cb05f0561 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 305841aa52d36c..d8a39a6b9c16f6 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 46d32885600dfd..d52416b39596b8 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 dc5c87c61fdcee..d1c8316ad061b2 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 0b58c71c1db7bf..44c2bafce4194e 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 00000000000000..f2b6c468df69f6 --- /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 fac49a7980f264..515856fa6be8af 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 00000000000000..92ef4fb4e35c94 --- /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 17494212777198..8c470acbf02fb0 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 00000000000000..63b3632f037844 --- /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 00000000000000..67087f3b4775b6 --- /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 00000000000000..b41a21a21b6a6e --- /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 2dd0880cbf8cb7..def7caf578b94f 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 00000000000000..22b0ef748f5f81 --- /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 147729a1d0b3e6..d3c6f7a5f70a1b 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 17494212777198..d9ca9a96ffe51e 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 00000000000000..7eb7de57b2f929 --- /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 00000000000000..81326330282776 --- /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 9778311bd8e58c..6551316fd0c645 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 00000000000000..a48c65d648b25a --- /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 c4a684381b17ce..a4eb24d4a4de4f 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 1cdae05833b981..794128832461ba 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 cb57f2c884e387..f33cd41f46a115 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 b1eac8e287bd89..7465de2dba7f1f 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/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx index 0223b90c370469..f80d52248b4849 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/usage/collectors.ts b/x-pack/legacy/plugins/lens/server/usage/collectors.ts index 274b72c33e59ac..666b3718d5125d 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/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 325fc28f92051b..16cfd34c95ab36 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 00000000000000..7d06e8b530011b --- /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 57368b52a2bced..5837a80ec30833 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 ee0058a6ef1aa2..9d1a4d75beba28 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 df302c42d48ed8..a7e98c83468ae4 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/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index 8e80e036dbb8bc..bd22b4b9cc5cee 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -12,6 +12,7 @@ import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; import { VectorStyleSymbolEditor } from './vector_style_symbol_editor'; import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; +import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; import { VectorStyle } from '../vector_style'; import { OrientationEditor } from './orientation/orientation_editor'; import { @@ -86,25 +87,14 @@ export class VectorStyleEditor extends Component { async _loadSupportedFeatures() { const supportedFeatures = await this.props.layer.getSource().getSupportedShapeTypes(); - const isPointsOnly = await this.props.loadIsPointsOnly(); - const isLinesOnly = await this.props.loadIsLinesOnly(); - if (!this._isMounted) { return; } - if ( - _.isEqual(supportedFeatures, this.state.supportedFeatures) && - isPointsOnly === this.state.isPointsOnly && - isLinesOnly === this.state.isLinesOnly - ) { - return; - } - let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; - if (isPointsOnly) { + if (this.props.isPointsOnly) { selectedFeature = VECTOR_SHAPE_TYPES.POINT; - } else if (isLinesOnly) { + } else if (this.props.isLinesOnly) { selectedFeature = VECTOR_SHAPE_TYPES.LINE; } @@ -112,12 +102,7 @@ export class VectorStyleEditor extends Component { !_.isEqual(supportedFeatures, this.state.supportedFeatures) || selectedFeature !== this.state.selectedFeature ) { - this.setState({ - supportedFeatures, - selectedFeature, - isPointsOnly, - isLinesOnly, - }); + this.setState({ supportedFeatures, selectedFeature }); } } @@ -264,6 +249,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 26e36cb97a7911..8da8cfaa71e2c6 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,98 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Should render categorical legend 1`] = ` -
- - - - - - - 0_format - - - - - - - - - - - - 10_format - - - - - - - - - - - - - - - foobar_label - - - - - - -
-`; +exports[`Should render categorical legend 1`] = `""`; exports[`Should render ranged legend 1`] = ` { - 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 ( ); } 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 dbf704c9cbe4ce..21c24e837b412a 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,7 +4,12 @@ * 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'; @@ -24,7 +29,7 @@ const mockField = { }, }; -test('Should render ranged legend', async () => { +test('Should render ranged legend', () => { const colorStyle = new DynamicColorProperty( { color: 'Blues', @@ -40,25 +45,15 @@ test('Should render ranged legend', async () => { ); const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; - }, + isPointsOnly: true, + isLinesOnly: false, }); - 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', async () => { +test('Should render categorical legend', () => { const colorStyle = new DynamicColorProperty( { useCustomColorRamp: true, @@ -84,20 +79,10 @@ test('Should render categorical legend', async () => { ); const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; - }, + isPointsOnly: true, + isLinesOnly: false, }); - 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(); }); 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 f2e56722268148..5a4da1a80c9187 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 bac3c96581967c..97ab7cb78015bc 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 @@ -8,7 +8,7 @@ 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 { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -31,6 +31,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; } @@ -165,12 +172,12 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return null; } - _renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + _renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }) { return ( ); @@ -180,11 +187,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return ; } - renderLegendDetailRow({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }) { if (this.isRanged()) { return this._renderRangeLegend(); } else if (this.hasBreaks()) { - return this._renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }); + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); } else { return null; } 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 00000000000000..e08c2875c310eb --- /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 658eb6a164556f..ebe2a322711fc3 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/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index b8fc428a62a529..7bd60ea6502bc3 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 ea80b188e16463..30d1c5726ba481 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 aa0badd5583d5b..c250d83720580c 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 4bae90c3165f20..3631613e7907cd 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 @@ -16,6 +16,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 +35,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 +55,11 @@ export function getDefaultProperties(mapColors = []) { symbolId: DEFAULT_ICON, }, }, + [VECTOR_STYLES.LABEL_BORDER_SIZE]: { + options: { + size: LABEL_BORDER_SIZES.SMALL, + }, + }, }; } @@ -103,7 +118,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', }, }, }; @@ -158,7 +179,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.ICON_ORIENTATION]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, fieldMetaOptions: { @@ -168,13 +189,13 @@ 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, field: undefined, @@ -185,7 +206,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 +217,16 @@ export function getDefaultDynamicProperties() { }, }, }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, }; } 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 944d65a21aae2e..f208917e209244 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/types/jobs.ts b/x-pack/legacy/plugins/ml/common/types/jobs.ts index 07c2be3e7f0b44..47f34f6568eed9 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 29831190824ad1..dba73c246c3d01 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 7fa47f3518b81b..d71a23f4782826 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 84c16360795ea5..66685188227101 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/annotations_table.js b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index f270d14b53e561..6c4e8925f369f9 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 d1beb360793f25..bc3ce88921110a 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, @@ -224,7 +224,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 75941edddeb56c..36faac45164f47 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, @@ -262,10 +262,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 b4821ddb564c91..8cbee27bdd9a81 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 89a5fafc491b56..00000000000000 --- 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 00000000000000..70538d4dc3a917 --- /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 f26c16c6ff77de..d868b9570f3378 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/checkbox_showcharts/index.d.ts b/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts similarity index 57% rename from x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.d.ts rename to x-pack/legacy/plugins/ml/public/application/components/controls/index.ts index 4d6952d3b3fc3b..f3e1ef83588679 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BehaviorSubject } from 'rxjs'; - -export const showCharts$: BehaviorSubject; +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 b7957b807591c9..32a0b530778180 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 c99d25a68f7225..00000000000000 --- 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 00000000000000..e1861b887b2a9d --- /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 fce538c0c8c7eb..cea3ef2a497b05 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 00000000000000..1f524dc1c2ffd0 --- /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 53d65d6622b947..00000000000000 --- 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 ec2fe7d1cdeac0..e30c48c10a1943 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 00000000000000..a03594a5f213e1 --- /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/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 fe5966524c7e5f..00000000000000 --- 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 7f5c1465686486..00000000000000 --- 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 00000000000000..1484f0a391b671 --- /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 b86118c451bb7c..f1d9dcb0ec7956 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 ( { + 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/navigation_menu/tabs.tsx b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx index 20fa2cca41231a..ac83d598f23826 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 523970dfe12f8a..ca6146f3e23b53 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/explorer/__tests__/explorer_directive.js b/x-pack/legacy/plugins/ml/public/application/explorer/__tests__/explorer_directive.js deleted file mode 100644 index 4626ee48b53f7a..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/__tests__/explorer_directive.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ngMock from 'ng_mock'; -import sinon from 'sinon'; -import expect from '@kbn/expect'; - -import { - uiChromeMock, - uiTimefilterMock, - uiTimeHistoryMock, -} from '../../contexts/ui/__mocks__/mocks_mocha'; -import * as useUiContextModule from '../../contexts/ui/use_ui_context'; -import * as UiTimefilterModule from 'ui/timefilter'; - -describe('ML - Anomaly Explorer Directive', () => { - let $scope; - let $compile; - let $element; - let stubContext; - let stubTimefilterFetch; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function($injector) { - stubContext = sinon.stub(useUiContextModule, 'useUiContext').callsFake(function fakeFn() { - return { - chrome: uiChromeMock, - timefilter: uiTimefilterMock, - timeHistory: uiTimeHistoryMock, - }; - }); - stubTimefilterFetch = sinon - .stub(UiTimefilterModule.timefilter, 'getFetch$') - .callsFake(uiTimefilterMock.getFetch$); - - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - stubContext.restore(); - stubTimefilterFetch.restore(); - $scope.$destroy(); - }); - - it('Initialize Anomaly Explorer Directive', done => { - ngMock.inject(function() { - expect(() => { - $element = $compile('')($scope); - }).to.not.throwError(); - - // directive has scope: false - const scope = $element.isolateScope(); - expect(scope).to.eql(undefined); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts index 1528a7ce7eee1f..a16081892cdc82 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts @@ -5,4 +5,4 @@ */ export { jobSelectionActionCreator } from './job_selection'; -export { loadExplorerData } from './load_explorer_data'; +export { useExplorerData } from './load_explorer_data'; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts b/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts index 76d66bfbbf12b0..994d67bfdb02c1 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts @@ -10,13 +10,10 @@ import { map } from 'rxjs/operators'; import { mlFieldFormatService } from '../../services/field_format_service'; import { mlJobService } from '../../services/job_service'; -import { createJobs, RestoredAppState } from '../explorer_utils'; +import { EXPLORER_ACTION } from '../explorer_constants'; +import { createJobs } from '../explorer_utils'; -export function jobSelectionActionCreator( - actionName: string, - selectedJobIds: string[], - { filterData, selectedCells, viewBySwimlaneFieldName }: RestoredAppState -) { +export function jobSelectionActionCreator(selectedJobIds: string[]) { return from(mlFieldFormatService.populateFormats(selectedJobIds)).pipe( map(resp => { if (resp.err) { @@ -32,13 +29,10 @@ export function jobSelectionActionCreator( const selectedJobs = jobs.filter(job => job.selected); return { - type: actionName, + type: EXPLORER_ACTION.JOB_SELECTION_CHANGE, payload: { loading: false, - selectedCells, selectedJobs, - viewBySwimlaneFieldName, - filterData, }, }; }) diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 6d4edd909fa8fd..ed734051342248 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -6,11 +6,12 @@ import memoizeOne from 'memoize-one'; import { isEqual } from 'lodash'; +import useObservable from 'react-use/lib/useObservable'; -import { forkJoin, of } from 'rxjs'; -import { mergeMap, tap } from 'rxjs/operators'; +import { forkJoin, of, Observable, Subject } from 'rxjs'; +import { mergeMap, switchMap, tap } from 'rxjs/operators'; -import { explorerChartsContainerServiceFactory } from '../explorer_charts/explorer_charts_container_service'; +import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service'; import { VIEW_BY_JOB_LABEL } from '../explorer_constants'; import { explorerService } from '../explorer_dashboard_service'; import { @@ -25,35 +26,82 @@ import { loadTopInfluencers, loadViewBySwimlane, loadViewByTopFieldValuesForSelectedTime, + AppStateSelectedCells, + ExplorerJob, + TimeRangeBounds, } from '../explorer_utils'; import { ExplorerState } from '../reducers'; +// Memoize the data fetching methods. +// wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument +// which will be considered by memoizeOne. This way we can add the `lastRefresh` argument as a +// caching parameter without having to change all the original functions which shouldn't care +// about this parameter. The generic type T retains and returns the type information of +// the original function. const memoizeIsEqual = (newArgs: any[], lastArgs: any[]) => isEqual(newArgs, lastArgs); +const wrapWithLastRefreshArg = any>(func: T) => { + return function(lastRefresh: number, ...args: Parameters): ReturnType { + return func.apply(null, args); + }; +}; +const memoize = any>(func: T) => { + return memoizeOne(wrapWithLastRefreshArg(func), memoizeIsEqual); +}; -// Memoize the data fetching methods -// TODO: We need to track an attribute that allows refetching when the date picker -// triggers a refresh, otherwise we'll get back the stale data. Note this was also -// an issue with the previous version and the custom caching done within the component. -const memoizedLoadAnnotationsTableData = memoizeOne(loadAnnotationsTableData, memoizeIsEqual); -const memoizedLoadDataForCharts = memoizeOne(loadDataForCharts, memoizeIsEqual); -const memoizedLoadFilteredTopInfluencers = memoizeOne(loadFilteredTopInfluencers, memoizeIsEqual); -const memoizedLoadOverallData = memoizeOne(loadOverallData, memoizeIsEqual); -const memoizedLoadTopInfluencers = memoizeOne(loadTopInfluencers, memoizeIsEqual); -const memoizedLoadViewBySwimlane = memoizeOne(loadViewBySwimlane, memoizeIsEqual); -const memoizedLoadAnomaliesTableData = memoizeOne(loadAnomaliesTableData, memoizeIsEqual); +const memoizedAnomalyDataChange = memoize(anomalyDataChange); +const memoizedLoadAnnotationsTableData = memoize( + loadAnnotationsTableData +); +const memoizedLoadDataForCharts = memoize(loadDataForCharts); +const memoizedLoadFilteredTopInfluencers = memoize( + loadFilteredTopInfluencers +); +const memoizedLoadOverallData = memoize(loadOverallData); +const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); +const memoizedLoadViewBySwimlane = memoize(loadViewBySwimlane); +const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); const dateFormatTz = getDateFormatTz(); +export interface LoadExplorerDataConfig { + bounds: TimeRangeBounds; + influencersFilterQuery: any; + lastRefresh: number; + noInfluencersConfigured: boolean; + selectedCells: AppStateSelectedCells | undefined; + selectedJobs: ExplorerJob[]; + swimlaneBucketInterval: any; + swimlaneLimit: number; + tableInterval: string; + tableSeverity: number; + viewBySwimlaneFieldName: string; +} + +export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { + return ( + arg !== undefined && + arg.bounds !== undefined && + arg.selectedJobs !== undefined && + arg.selectedJobs !== null && + arg.viewBySwimlaneFieldName !== undefined + ); +}; + /** * Fetches the data necessary for the Anomaly Explorer using observables. * - * @param state ExplorerState + * @param config LoadExplorerDataConfig * * @return Partial */ -export function loadExplorerData(state: ExplorerState) { +function loadExplorerData(config: LoadExplorerDataConfig): Observable> { + if (!isLoadExplorerDataConfig(config)) { + return of({}); + } + const { bounds, + lastRefresh, influencersFilterQuery, noInfluencersConfigured, selectedCells, @@ -63,19 +111,12 @@ export function loadExplorerData(state: ExplorerState) { tableInterval, tableSeverity, viewBySwimlaneFieldName, - } = state; - - if (selectedJobs === null || bounds === undefined || viewBySwimlaneFieldName === undefined) { - return of({}); - } - - // TODO This factory should be refactored so we can load the charts using memoization. - const updateCharts = explorerChartsContainerServiceFactory(explorerService.setCharts); + } = config; const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); const jobIds = - selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL + selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL ? selectedCells.lanes : selectedJobs.map(d => d.id); @@ -89,12 +130,14 @@ export function loadExplorerData(state: ExplorerState) { // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues return forkJoin({ annotationsData: memoizedLoadAnnotationsTableData( + lastRefresh, selectedCells, selectedJobs, swimlaneBucketInterval.asSeconds(), bounds ), anomalyChartRecords: memoizedLoadDataForCharts( + lastRefresh, jobIds, timerange.earliestMs, timerange.latestMs, @@ -105,6 +148,7 @@ export function loadExplorerData(state: ExplorerState) { influencers: selectionInfluencers.length === 0 ? memoizedLoadTopInfluencers( + lastRefresh, jobIds, timerange.earliestMs, timerange.latestMs, @@ -113,8 +157,14 @@ export function loadExplorerData(state: ExplorerState) { influencersFilterQuery ) : Promise.resolve({}), - overallState: memoizedLoadOverallData(selectedJobs, swimlaneBucketInterval, bounds), + overallState: memoizedLoadOverallData( + lastRefresh, + selectedJobs, + swimlaneBucketInterval, + bounds + ), tableData: memoizedLoadAnomaliesTableData( + lastRefresh, selectedCells, selectedJobs, dateFormatTz, @@ -126,7 +176,7 @@ export function loadExplorerData(state: ExplorerState) { influencersFilterQuery ), topFieldValues: - selectedCells !== null && selectedCells.showTopFieldValues === true + selectedCells !== undefined && selectedCells.showTopFieldValues === true ? loadViewByTopFieldValuesForSelectedTime( timerange.earliestMs, timerange.latestMs, @@ -143,10 +193,22 @@ export function loadExplorerData(state: ExplorerState) { tap(explorerService.setViewBySwimlaneLoading), // Trigger a side-effect to update the charts. tap(({ anomalyChartRecords }) => { - if (selectedCells !== null && Array.isArray(anomalyChartRecords)) { - updateCharts(anomalyChartRecords, timerange.earliestMs, timerange.latestMs); + if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { + memoizedAnomalyDataChange( + lastRefresh, + anomalyChartRecords, + timerange.earliestMs, + timerange.latestMs, + tableSeverity + ); } else { - updateCharts([], timerange.earliestMs, timerange.latestMs); + memoizedAnomalyDataChange( + lastRefresh, + [], + timerange.earliestMs, + timerange.latestMs, + tableSeverity + ); } }), // Load view-by swimlane data and filtered top influencers. @@ -161,6 +223,7 @@ export function loadExplorerData(state: ExplorerState) { anomalyChartRecords !== undefined && anomalyChartRecords.length > 0 ? memoizedLoadFilteredTopInfluencers( + lastRefresh, jobIds, timerange.earliestMs, timerange.latestMs, @@ -171,6 +234,7 @@ export function loadExplorerData(state: ExplorerState) { ) : Promise.resolve(influencers), viewBySwimlaneState: memoizedLoadViewBySwimlane( + lastRefresh, topFieldValues, { earliest: overallState.overallSwimlaneData.earliest, @@ -183,7 +247,10 @@ export function loadExplorerData(state: ExplorerState) { noInfluencersConfigured ), }), - ({ annotationsData, overallState, tableData }, { influencers, viewBySwimlaneState }) => { + ( + { annotationsData, overallState, tableData }, + { influencers, viewBySwimlaneState } + ): Partial => { return { annotationsData, influencers, @@ -195,3 +262,13 @@ export function loadExplorerData(state: ExplorerState) { ) ); } + +const loadExplorerData$ = new Subject(); +const explorerData$ = loadExplorerData$.pipe( + switchMap((config: LoadExplorerDataConfig) => loadExplorerData(config)) +); + +export const useExplorerData = (): [Partial | undefined, (d: any) => void] => { + const explorerData = useObservable(explorerData$); + return [explorerData, c => loadExplorerData$.next(c)]; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts index de58b9228c0769..b8df021990f584 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts @@ -6,13 +6,17 @@ import { FC } from 'react'; -import { State } from 'ui/state_management/state'; +import { UrlState } from '../util/url_state'; -import { JobSelectService$ } from '../components/job_selector/job_select_service_utils'; +import { JobSelection } from '../components/job_selector/use_job_selection'; + +import { ExplorerState } from '../explorer/reducers'; +import { AppStateSelectedCells } from '../explorer/explorer_utils'; declare interface ExplorerProps { - globalState: State; - jobSelectService$: JobSelectService$; + explorerState: ExplorerState; + showCharts: boolean; + setSelectedCells: (swimlaneSelectedCells: AppStateSelectedCells) => void; } export const Explorer: FC; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js index bcac1b6405ff8b..79071319965781 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js @@ -10,9 +10,10 @@ import PropTypes from 'prop-types'; import React, { createRef } from 'react'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import DragSelect from 'dragselect/dist/ds.min.js'; -import { merge, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { @@ -24,7 +25,6 @@ import { EuiSpacer, } from '@elastic/eui'; -import { annotationsRefresh$ } from '../services/annotations_service'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; import { @@ -36,7 +36,6 @@ import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; import { KqlFilterBar } from '../components/kql_filter_bar'; import { TimeBuckets } from '../util/time_buckets'; -import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -45,12 +44,11 @@ import { } from './explorer_dashboard_service'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; -import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts'; +import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { JobSelector } from '../components/job_selector'; -import { SelectInterval, interval$ } from '../components/controls/select_interval/select_interval'; +import { SelectInterval } from '../components/controls/select_interval/select_interval'; import { SelectLimit, limit$ } from './select_limit/select_limit'; -import { SelectSeverity, severity$ } from '../components/controls/select_severity/select_severity'; -import { injectObservablesAsProps } from '../util/observable_utils'; +import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { getKqlQueryValues, removeFilterFromQueryString, @@ -58,9 +56,8 @@ import { escapeParens, escapeDoubleQuotes, } from '../components/kql_filter_bar/utils'; -import { mlJobService } from '../services/job_service'; -import { getDateFormatTz, restoreAppState } from './explorer_utils'; +import { getDateFormatTz } from './explorer_utils'; import { getSwimlaneContainerWidth } from './legacy_utils'; import { @@ -80,8 +77,6 @@ import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/pub import { timefilter } from 'ui/timefilter'; import { toastNotifications } from 'ui/notify'; -import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; - function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ value: option, @@ -97,571 +92,484 @@ const ExplorerPage = ({ children, jobSelectorProps, resizeRef }) => ( ); -export const Explorer = injectI18n( - injectObservablesAsProps( - { - annotationsRefresh: annotationsRefresh$, - explorerState: explorerService.state$, - showCharts: showCharts$, - }, - class Explorer extends React.Component { - static propTypes = { - annotationsRefresh: PropTypes.bool, - explorerState: PropTypes.object.isRequired, - explorer: PropTypes.object, - globalState: PropTypes.object.isRequired, - jobSelectService$: PropTypes.object.isRequired, - showCharts: PropTypes.bool.isRequired, - }; - - _unsubscribeAll = new Subject(); - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect = new DragSelect({ - selectables: document.getElementsByClassName('sl-cell'), - callback(elements) { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - // Listens to render updates of the swimlanes to update dragSelect - swimlaneRenderDoneListener = () => { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - }; - - resizeRef = createRef(); - resizeChecker = undefined; - resizeHandler = () => { - explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); - }; - - componentDidMount() { - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - explorerService.setBounds(timefilter.getActiveBounds()); - - // Refresh all the data when the time range is altered. - merge(mlTimefilterRefresh$, timefilter.getFetch$()) - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe(() => { - explorerService.setBounds(timefilter.getActiveBounds()); - }); - - limit$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => d.val) - ) - .subscribe(explorerService.setSwimlaneLimit); - - interval$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => ({ tableInterval: d.val })) - ) - .subscribe(explorerService.setState); - - severity$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => ({ tableSeverity: d.val })) - ) - .subscribe(explorerService.setState); - - // Required to redraw the time series chart when the container is resized. - this.resizeChecker = new ResizeChecker(this.resizeRef.current); - this.resizeChecker.on('resize', this.resizeHandler); - - // restore state stored in URL via AppState and subscribe to - // job updates via job selector. - if (mlJobService.jobs.length > 0) { - let initialized = false; - - this.props.jobSelectService$ - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe(({ selection }) => { - if (selection !== undefined) { - if (!initialized) { - explorerService.initialize( - selection, - restoreAppState(this.props.explorerState.appState) - ); - initialized = true; - } else { - explorerService.updateJobSelection( - selection, - restoreAppState(this.props.explorerState.appState) - ); - } - } - }); - } else { - explorerService.clearJobs(); - } +export class Explorer extends React.Component { + static propTypes = { + explorerState: PropTypes.object.isRequired, + setSelectedCells: PropTypes.func.isRequired, + showCharts: PropTypes.bool.isRequired, + }; + + _unsubscribeAll = new Subject(); + // make sure dragSelect is only available if the mouse pointer is actually over a swimlane + disableDragSelectOnMouseLeave = true; + + dragSelect = new DragSelect({ + selectables: document.getElementsByClassName('sl-cell'), + callback(elements) { + if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { + elements = [elements[0]]; } - componentWillUnmount() { - this._unsubscribeAll.next(); - this._unsubscribeAll.complete(); - this.resizeChecker.destroy(); + if (elements.length > 0) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); } - resetCache() { - this.anomaliesTablePreviousArgs = null; + this.disableDragSelectOnMouseLeave = true; + }, + onDragStart() { + if (ALLOW_CELL_RANGE_SELECTION) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.DRAG_START, + }); + this.disableDragSelectOnMouseLeave = false; } - - componentDidUpdate() { - // TODO migrate annotations update - if (this.props.annotationsRefresh === true) { - annotationsRefresh$.next(false); - } + }, + onElementSelect() { + if (ALLOW_CELL_RANGE_SELECTION) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.ELEMENT_SELECT, + }); } - - viewByChangeHandler = e => explorerService.setViewBySwimlaneFieldName(e.target.value); - - isSwimlaneSelectActive = false; - onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); - onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); - setSwimlaneSelectActive = active => { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - this.isSwimlaneSelectActive = active; - } - }; - - // Listener for click events in the swimlane to load corresponding anomaly data. - swimlaneCellClick = selectedCells => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCells).length === 0) { - explorerService.clearSelection(); - } else { - explorerService.setSelectedCells(selectedCells); - } - }; - // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes - // and will cause a syntax error when called with getKqlQueryValues - applyFilter = (fieldName, fieldValue, action) => { - const { filterActive, indexPattern, queryString } = this.props.explorerState; - - let newQueryString = ''; - const operator = 'and '; - const sanitizedFieldName = escapeParens(fieldName); - const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); - - if (action === FILTER_ACTION.ADD) { - // Don't re-add if already exists in the query - const queryPattern = getQueryPattern(fieldName, fieldValue); - if (queryString.match(queryPattern) !== null) { - return; - } - newQueryString = `${ - queryString ? `${queryString} ${operator}` : '' - }${sanitizedFieldName}:"${sanitizedFieldValue}"`; - } else if (action === FILTER_ACTION.REMOVE) { - if (filterActive === false) { - return; - } else { - newQueryString = removeFilterFromQueryString( - queryString, - sanitizedFieldName, - sanitizedFieldValue - ); - } - } - - try { - const queryValues = getKqlQueryValues(`${newQueryString}`, indexPattern); - this.applyInfluencersFilterQuery(queryValues); - } catch (e) { - console.log('Invalid kuery syntax', e); // eslint-disable-line no-console - - toastNotifications.addDanger( - this.props.intl.formatMessage({ - id: 'xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', - defaultMessage: - 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', - }) - ); - } - }; - - applyInfluencersFilterQuery = payload => { - const { filterQuery: influencersFilterQuery } = payload; - - if ( - influencersFilterQuery.match_all && - Object.keys(influencersFilterQuery.match_all).length === 0 - ) { - explorerService.clearInfluencerFilterSettings(); - } else { - explorerService.setInfluencerFilterSettings(payload); - } - }; - - render() { - const { globalState, intl, jobSelectService$, showCharts } = this.props; - - const { - annotationsData, - anomalyChartRecords, - chartsData, - filterActive, - filterPlaceHolder, - indexPattern, - influencers, - loading, - maskAll, - noInfluencersConfigured, - overallSwimlaneData, + }, + }); + + // Listens to render updates of the swimlanes to update dragSelect + swimlaneRenderDoneListener = () => { + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); + }; + + resizeRef = createRef(); + resizeChecker = undefined; + resizeHandler = () => { + explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); + }; + + componentDidMount() { + limit$ + .pipe( + takeUntil(this._unsubscribeAll), + map(d => d.val) + ) + .subscribe(explorerService.setSwimlaneLimit); + + // Required to redraw the time series chart when the container is resized. + this.resizeChecker = new ResizeChecker(this.resizeRef.current); + this.resizeChecker.on('resize', this.resizeHandler); + } + + componentWillUnmount() { + this._unsubscribeAll.next(); + this._unsubscribeAll.complete(); + this.resizeChecker.destroy(); + } + + resetCache() { + this.anomaliesTablePreviousArgs = null; + } + + viewByChangeHandler = e => explorerService.setViewBySwimlaneFieldName(e.target.value); + + isSwimlaneSelectActive = false; + onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); + onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); + setSwimlaneSelectActive = active => { + if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { + this.dragSelect.stop(); + this.isSwimlaneSelectActive = active; + return; + } + if (!this.isSwimlaneSelectActive && active) { + this.dragSelect.start(); + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); + this.isSwimlaneSelectActive = active; + } + }; + + // Listener for click events in the swimlane to load corresponding anomaly data. + swimlaneCellClick = selectedCells => { + // If selectedCells is an empty object we clear any existing selection, + // otherwise we save the new selection in AppState and update the Explorer. + if (Object.keys(selectedCells).length === 0) { + this.props.setSelectedCells(); + } else { + this.props.setSelectedCells(selectedCells); + } + }; + // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes + // and will cause a syntax error when called with getKqlQueryValues + applyFilter = (fieldName, fieldValue, action) => { + const { filterActive, indexPattern, queryString } = this.props.explorerState; + + let newQueryString = ''; + const operator = 'and '; + const sanitizedFieldName = escapeParens(fieldName); + const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); + + if (action === FILTER_ACTION.ADD) { + // Don't re-add if already exists in the query + const queryPattern = getQueryPattern(fieldName, fieldValue); + if (queryString.match(queryPattern) !== null) { + return; + } + newQueryString = `${ + queryString ? `${queryString} ${operator}` : '' + }${sanitizedFieldName}:"${sanitizedFieldValue}"`; + } else if (action === FILTER_ACTION.REMOVE) { + if (filterActive === false) { + return; + } else { + newQueryString = removeFilterFromQueryString( queryString, - selectedCells, - selectedJobs, - swimlaneContainerWidth, - tableData, - tableQueryString, - viewByLoadedForTimeFormatted, - viewBySwimlaneData, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - } = this.props.explorerState; - - const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); - const jobSelectorProps = { - dateFormatTz: getDateFormatTz(), - globalState, - jobSelectService$, - selectedJobIds, - selectedGroups, - }; - - const noJobsFound = selectedJobs === null || selectedJobs.length === 0; - const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; - - if (loading === true) { - return ( - - - - ); - } - - if (noJobsFound) { - return ( - - - - ); - } - - if (noJobsFound && hasResults === false) { - return ( - - - - ); - } - - const mainColumnWidthClassName = - noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; - const mainColumnClasses = `column ${mainColumnWidthClassName}`; - - const showOverallSwimlane = - overallSwimlaneData !== null && - overallSwimlaneData.laneLabels && - overallSwimlaneData.laneLabels.length > 0; - const showViewBySwimlane = - viewBySwimlaneData !== null && - viewBySwimlaneData.laneLabels && - viewBySwimlaneData.laneLabels.length > 0; - - return ( - -
- {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */} - - - {noInfluencersConfigured === false && influencers !== undefined && ( -
- -
- )} - - {noInfluencersConfigured && ( -
-
- )} - - {noInfluencersConfigured === false && ( -
- - - - -
- )} + sanitizedFieldName, + sanitizedFieldValue + ); + } + } -
- - - + try { + const queryValues = getKqlQueryValues(`${newQueryString}`, indexPattern); + this.applyInfluencersFilterQuery(queryValues); + } catch (e) { + console.log('Invalid kuery syntax', e); // eslint-disable-line no-console + + toastNotifications.addDanger( + i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', { + defaultMessage: + 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', + }) + ); + } + }; + + applyInfluencersFilterQuery = payload => { + const { filterQuery: influencersFilterQuery } = payload; + + if ( + influencersFilterQuery.match_all && + Object.keys(influencersFilterQuery.match_all).length === 0 + ) { + explorerService.clearInfluencerFilterSettings(); + } else { + explorerService.setInfluencerFilterSettings(payload); + } + }; + + render() { + const { showCharts } = this.props; + + const { + annotationsData, + chartsData, + filterActive, + filterPlaceHolder, + indexPattern, + influencers, + loading, + maskAll, + noInfluencersConfigured, + overallSwimlaneData, + queryString, + selectedCells, + selectedJobs, + severity, + swimlaneContainerWidth, + tableData, + tableQueryString, + viewByLoadedForTimeFormatted, + viewBySwimlaneData, + viewBySwimlaneDataLoading, + viewBySwimlaneFieldName, + viewBySwimlaneOptions, + } = this.props.explorerState; + + const jobSelectorProps = { + dateFormatTz: getDateFormatTz(), + }; + + const noJobsFound = selectedJobs === null || selectedJobs.length === 0; + const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; + + if (loading === true) { + return ( + + + + ); + } -
- {showOverallSwimlane && ( - - )} -
+ if (noJobsFound) { + return ( + + + + ); + } - {viewBySwimlaneOptions.length > 0 && ( - <> - - - - - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( - - )} -
-
-
-
- - {showViewBySwimlane && ( - <> - -
- -
- - )} - - {viewBySwimlaneDataLoading && } - - {!showViewBySwimlane && - !viewBySwimlaneDataLoading && - viewBySwimlaneFieldName !== null && ( - - )} - - )} + if (noJobsFound && hasResults === false) { + return ( + + + + ); + } - {annotationsData.length > 0 && ( - <> - - - - - - - - )} + const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; + const mainColumnClasses = `column ${mainColumnWidthClassName}`; + + const showOverallSwimlane = + overallSwimlaneData !== null && + overallSwimlaneData.laneLabels && + overallSwimlaneData.laneLabels.length > 0; + const showViewBySwimlane = + viewBySwimlaneData !== null && + viewBySwimlaneData.laneLabels && + viewBySwimlaneData.laneLabels.length > 0; + + const bounds = timefilter.getActiveBounds(); + + return ( + +
+ {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */} + + + {noInfluencersConfigured === false && influencers !== undefined && ( +
+ +
+ )} + + {noInfluencersConfigured && ( +
+
+ )} + + {noInfluencersConfigured === false && ( +
+ + + + +
+ )} - - - +
+ + + + +
+ {showOverallSwimlane && ( + + )} +
- - + {viewBySwimlaneOptions.length > 0 && ( + <> + + - + - + - + + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( + + )} +
- {anomalyChartRecords.length > 0 && selectedCells !== null && ( - - - - - - )}
- + {showViewBySwimlane && ( + <> + +
+ +
+ + )} + + {viewBySwimlaneDataLoading && } -
- {showCharts && } -
+ {!showViewBySwimlane && + !viewBySwimlaneDataLoading && + viewBySwimlaneFieldName !== null && ( + + )} + + )} - 0 && ( + <> + + + + -
+ + + + )} + + + + + + + + + + + + + + + + + {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( + + + + + + )} + + + + +
+ {showCharts && }
- - ); - } - } - ) -); + + +
+
+ + ); + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap index df76b049e98376..1c0124b90ae77d 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap @@ -1,15 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 1`] = ` -Object { - "chartsPerRow": 1, - "seriesToPlot": Array [], - "timeFieldName": "timestamp", - "tooManyBuckets": false, -} -`; - -exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = ` Object { "chartsPerRow": 1, "seriesToPlot": Array [ @@ -69,7 +60,7 @@ Object { } `; -exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 3`] = ` +exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = ` Object { "chartsPerRow": 1, "seriesToPlot": Array [ diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 757fd00192fc8a..ce819a8d6dc8c8 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -32,7 +32,6 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind import { TimeBuckets } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; -import { severity$ } from '../../components/controls/select_severity/select_severity'; import { CHART_TYPE } from '../explorer_constants'; @@ -51,6 +50,7 @@ export const ExplorerChartDistribution = injectI18n( class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, + severity: PropTypes.number, }; componentDidMount() { @@ -66,6 +66,7 @@ export const ExplorerChartDistribution = injectI18n( const element = this.rootNode; const config = this.props.seriesConfig; + const severity = this.props.severity; if (typeof config === 'undefined' || Array.isArray(config.chartData) === false) { // just return so the empty directive renders without an error later on @@ -400,13 +401,12 @@ export const ExplorerChartDistribution = injectI18n( .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. - const threshold = severity$.getValue(); dots .attr('cx', d => lineChartXScale(d.date)) .attr('cy', d => lineChartYScale(d[CHART_Y_ATTRIBUTE])) .attr('class', d => { let markerClass = 'metric-value'; - if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) { + if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity) { markerClass += ' anomaly-marker '; markerClass += getSeverityWithLow(d.anomalyScore).id; } diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 5319692b00a385..583375c87007e4 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -42,7 +42,6 @@ import { TimeBuckets } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; -import { severity$ } from '../../components/controls/select_severity/select_severity'; import { injectI18n } from '@kbn/i18n/react'; @@ -54,6 +53,7 @@ export const ExplorerChartSingleMetric = injectI18n( static propTypes = { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, + severity: PropTypes.number, }; componentDidMount() { @@ -69,6 +69,7 @@ export const ExplorerChartSingleMetric = injectI18n( const element = this.rootNode; const config = this.props.seriesConfig; + const severity = this.props.severity; if (typeof config === 'undefined' || Array.isArray(config.chartData) === false) { // just return so the empty directive renders without an error later on @@ -312,13 +313,12 @@ export const ExplorerChartSingleMetric = injectI18n( .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. - const threshold = severity$.getValue(); dots .attr('cx', d => lineChartXScale(d.date)) .attr('cy', d => lineChartYScale(d.value)) .attr('class', d => { let markerClass = 'metric-value'; - if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) { + if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity) { markerClass += ` anomaly-marker ${getSeverityWithLow(d.anomalyScore).id}`; } return markerClass; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 140c5a87056e51..99de38c1e0a84a 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -52,7 +52,7 @@ function getChartId(series) { } // Wrapper for a single explorer chart -function ExplorerChartContainer({ series, tooManyBuckets, wrapLabel }) { +function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) { const { detectorLabel, entityFields } = series; const chartType = getChartType(series); @@ -121,10 +121,20 @@ function ExplorerChartContainer({ series, tooManyBuckets, wrapLabel }) { chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - + ); } - return ; + return ( + + ); })()} ); @@ -146,7 +156,7 @@ export class ExplorerChartsContainer extends React.Component { } render() { - const { chartsPerRow, seriesToPlot, tooManyBuckets } = this.props; + const { chartsPerRow, seriesToPlot, severity, tooManyBuckets } = this.props; // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. // If that's the case we trick it doing that with the following settings: @@ -166,6 +176,7 @@ export class ExplorerChartsContainer extends React.Component { > diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js index f0b94cb724c577..4b2d307e72c665 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js @@ -4,6 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import { mount, shallow } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; + +import { chartLimits } from '../../util/chart_utils'; + +import { getDefaultChartsData } from './explorer_charts_container_service'; +import { ExplorerChartsContainer } from './explorer_charts_container'; + import './explorer_chart_single_metric.test.mocks'; import { chartData } from './__mocks__/mock_chart_data'; import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; @@ -38,17 +48,12 @@ jest.mock( getBasePath: () => { return ''; }, + getInjected: () => true, }), { virtual: true } ); -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; -import React from 'react'; - -import { chartLimits } from '../../util/chart_utils'; -import { getDefaultChartsData } from './explorer_charts_container_service'; - -import { ExplorerChartsContainer } from './explorer_charts_container'; +jest.mock('ui/new_platform'); describe('ExplorerChartsContainer', () => { const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 }; @@ -58,7 +63,11 @@ describe('ExplorerChartsContainer', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Minimal Initialization', () => { - const wrapper = shallowWithIntl(); + const wrapper = shallow( + + + + ); expect(wrapper.html()).toBe( '
' @@ -78,7 +87,11 @@ describe('ExplorerChartsContainer', () => { chartsPerRow: 1, tooManyBuckets: false, }; - const wrapper = mountWithIntl(); + const wrapper = mount( + + + + ); // We test child components with snapshots separately // so we just do some high level sanity check here. @@ -101,7 +114,11 @@ describe('ExplorerChartsContainer', () => { chartsPerRow: 1, tooManyBuckets: false, }; - const wrapper = mountWithIntl(); + const wrapper = mount( + + + + ); // We test child components with snapshots separately // so we just do some high level sanity check here. diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts index ccd52a26f2abcd..962072b974867d 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts @@ -13,6 +13,9 @@ export declare interface ExplorerChartsData { export declare const getDefaultChartsData: () => ExplorerChartsData; -export declare const explorerChartsContainerServiceFactory: ( - callback: (data: ExplorerChartsData) => void -) => (anomalyRecords: any[], earliestMs: number, latestMs: number) => void; +export declare const anomalyDataChange: ( + anomalyRecords: any[], + earliestMs: number, + latestMs: number, + severity?: number +) => void; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js index 4aad4fba857460..e0fb97a81f587c 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js @@ -23,8 +23,8 @@ import { } from '../../../../common/util/job_utils'; import { mlResultsService } from '../../services/results_service'; import { mlJobService } from '../../services/job_service'; -import { severity$ } from '../../components/controls/select_severity/select_severity'; import { getChartContainerWidth } from '../legacy_utils'; +import { explorerService } from '../explorer_dashboard_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -38,593 +38,581 @@ export function getDefaultChartsData() { }; } -export function explorerChartsContainerServiceFactory(callback) { - const CHART_MAX_POINTS = 500; - const ANOMALIES_MAX_RESULTS = 500; - const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. - const ML_TIME_FIELD_NAME = 'timestamp'; - const USE_OVERALL_CHART_LIMITS = false; - const MAX_CHARTS_PER_ROW = 4; - - callback(getDefaultChartsData()); +const CHART_MAX_POINTS = 500; +const ANOMALIES_MAX_RESULTS = 500; +const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. +const ML_TIME_FIELD_NAME = 'timestamp'; +const USE_OVERALL_CHART_LIMITS = false; +const MAX_CHARTS_PER_ROW = 4; + +// callback(getDefaultChartsData()); + +export const anomalyDataChange = function(anomalyRecords, earliestMs, latestMs, severity = 0) { + const data = getDefaultChartsData(); + + const filteredRecords = anomalyRecords.filter(record => { + return Number(record.record_score) >= severity; + }); + const allSeriesRecords = processRecordsForDisplay(filteredRecords); + // Calculate the number of charts per row, depending on the width available, to a max of 4. + const chartsContainerWidth = getChartContainerWidth(); + let chartsPerRow = Math.min( + Math.max(Math.floor(chartsContainerWidth / 550), 1), + MAX_CHARTS_PER_ROW + ); + if (allSeriesRecords.length === 1) { + chartsPerRow = 1; + } - const anomalyDataChange = function(anomalyRecords, earliestMs, latestMs) { - const data = getDefaultChartsData(); + data.chartsPerRow = chartsPerRow; - const threshold = severity$.getValue(); + // Build the data configs of the anomalies to be displayed. + // TODO - implement paging? + // For now just take first 6 (or 8 if 4 charts per row). + const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); + const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); + const seriesConfigs = recordsToPlot.map(buildConfig); - const filteredRecords = anomalyRecords.filter(record => { - return Number(record.record_score) >= threshold.val; - }); - const allSeriesRecords = processRecordsForDisplay(filteredRecords); - // Calculate the number of charts per row, depending on the width available, to a max of 4. - const chartsContainerWidth = getChartContainerWidth(); - let chartsPerRow = Math.min( - Math.max(Math.floor(chartsContainerWidth / 550), 1), - MAX_CHARTS_PER_ROW - ); - if (allSeriesRecords.length === 1) { - chartsPerRow = 1; - } + // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. + data.tooManyBuckets = false; + const chartWidth = Math.floor(chartsContainerWidth / chartsPerRow); + const { chartRange, tooManyBuckets } = calculateChartRange( + seriesConfigs, + earliestMs, + latestMs, + chartWidth, + recordsToPlot, + data.timeFieldName + ); + data.tooManyBuckets = tooManyBuckets; - data.chartsPerRow = chartsPerRow; - - // Build the data configs of the anomalies to be displayed. - // TODO - implement paging? - // For now just take first 6 (or 8 if 4 charts per row). - const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); - const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); - const seriesConfigs = recordsToPlot.map(buildConfig); - - // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. - data.tooManyBuckets = false; - const chartWidth = Math.floor(chartsContainerWidth / chartsPerRow); - const { chartRange, tooManyBuckets } = calculateChartRange( - seriesConfigs, - earliestMs, - latestMs, - chartWidth, - recordsToPlot, - data.timeFieldName - ); - data.tooManyBuckets = tooManyBuckets; + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map(config => ({ + ...config, + loading: true, + chartData: null, + })); - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map(config => ({ - ...config, - loading: true, - chartData: null, - })); + explorerService.setCharts({ ...data }); - callback(data); + if (seriesConfigs.length === 0) { + return; + } - // Query 1 - load the raw metric data. - function getMetricData(config, range) { - const { jobId, detectorIndex, entityFields, interval } = config; + // Query 1 - load the raw metric data. + function getMetricData(config, range) { + const { jobId, detectorIndex, entityFields, interval } = config; - const job = mlJobService.getJob(jobId); + const job = mlJobService.getJob(jobId); - // If source data can be plotted, use that, otherwise model plot will be available. - const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); - if (useSourceData === true) { - const datafeedQuery = _.get(config, 'datafeedConfig.query', null); - return mlResultsService - .getMetricData( - config.datafeedConfig.indices, - config.entityFields, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, - range.min, - range.max, - config.interval - ) - .toPromise(); - } else { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (_.has(detector, 'partition_field_name')) { - const partitionEntity = _.find(entityFields, { - fieldName: detector.partition_field_name, - }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } + // If source data can be plotted, use that, otherwise model plot will be available. + const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); + if (useSourceData === true) { + const datafeedQuery = _.get(config, 'datafeedConfig.query', null); + return mlResultsService + .getMetricData( + config.datafeedConfig.indices, + config.entityFields, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.interval + ) + .toPromise(); + } else { + // Extract the partition, by, over fields on which to filter. + const criteriaFields = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (_.has(detector, 'partition_field_name')) { + const partitionEntity = _.find(entityFields, { + fieldName: detector.partition_field_name, + }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); } + } - if (_.has(detector, 'over_field_name')) { - const overEntity = _.find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } + if (_.has(detector, 'over_field_name')) { + const overEntity = _.find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); } + } - if (_.has(detector, 'by_field_name')) { - const byEntity = _.find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } + if (_.has(detector, 'by_field_name')) { + const byEntity = _.find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); } - - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - return mlResultsService - .getModelPlotOutput( - jobId, - detectorIndex, - criteriaFields, - range.min, - range.max, - interval - ) - .toPromise() - .then(resp => { - // Return data in format required by the explorer charts. - const results = resp.results; - Object.keys(results).forEach(time => { - obj.results[time] = results[time].actual; - }); - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); - }); } - } - // Query 2 - load the anomalies. - // Criteria to return the records for this series are the detector_index plus - // the specific combination of 'entity' fields i.e. the partition / by / over fields. - function getRecordsForCriteria(config, range) { - let criteria = []; - criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); - criteria = criteria.concat(config.entityFields); - return mlResultsService - .getRecordsForCriteria( - [config.jobId], - criteria, - 0, - range.min, - range.max, - ANOMALIES_MAX_RESULTS - ) - .toPromise(); - } + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; - // Query 3 - load any scheduled events for the job. - function getScheduledEvents(config, range) { - return mlResultsService - .getScheduledEventsByBucket( - [config.jobId], - range.min, - range.max, - config.interval, - 1, - MAX_SCHEDULED_EVENTS - ) - .toPromise(); + return mlResultsService + .getModelPlotOutput(jobId, detectorIndex, criteriaFields, range.min, range.max, interval) + .toPromise() + .then(resp => { + // Return data in format required by the explorer charts. + const results = resp.results; + Object.keys(results).forEach(time => { + obj.results[time] = results[time].actual; + }); + resolve(obj); + }) + .catch(resp => { + reject(resp); + }); + }); } + } - // Query 4 - load context data distribution - function getEventDistribution(config, range) { - const chartType = getChartType(config); - - let splitField; - let filterField = null; - - // Define splitField and filterField based on chartType - if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { - splitField = config.entityFields.find(f => f.fieldType === 'by'); - filterField = config.entityFields.find(f => f.fieldType === 'partition'); - } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { - splitField = config.entityFields.find(f => f.fieldType === 'over'); - filterField = config.entityFields.find(f => f.fieldType === 'partition'); - } + // Query 2 - load the anomalies. + // Criteria to return the records for this series are the detector_index plus + // the specific combination of 'entity' fields i.e. the partition / by / over fields. + function getRecordsForCriteria(config, range) { + let criteria = []; + criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); + criteria = criteria.concat(config.entityFields); + return mlResultsService + .getRecordsForCriteria( + [config.jobId], + criteria, + 0, + range.min, + range.max, + ANOMALIES_MAX_RESULTS + ) + .toPromise(); + } - const datafeedQuery = _.get(config, 'datafeedConfig.query', null); - return mlResultsService.getEventDistributionData( - config.datafeedConfig.indices, - splitField, - filterField, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, + // Query 3 - load any scheduled events for the job. + function getScheduledEvents(config, range) { + return mlResultsService + .getScheduledEventsByBucket( + [config.jobId], range.min, range.max, - config.interval - ); + config.interval, + 1, + MAX_SCHEDULED_EVENTS + ) + .toPromise(); + } + + // Query 4 - load context data distribution + function getEventDistribution(config, range) { + const chartType = getChartType(config); + + let splitField; + let filterField = null; + + // Define splitField and filterField based on chartType + if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { + splitField = config.entityFields.find(f => f.fieldType === 'by'); + filterField = config.entityFields.find(f => f.fieldType === 'partition'); + } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { + splitField = config.entityFields.find(f => f.fieldType === 'over'); + filterField = config.entityFields.find(f => f.fieldType === 'partition'); } - // first load and wait for required data, - // only after that trigger data processing and page render. - // TODO - if query returns no results e.g. source data has been deleted, - // display a message saying 'No data between earliest/latest'. - const seriesPromises = seriesConfigs.map(seriesConfig => - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) + const datafeedQuery = _.get(config, 'datafeedConfig.query', null); + return mlResultsService.getEventDistributionData( + config.datafeedConfig.indices, + splitField, + filterField, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.interval ); + } - function processChartData(response, seriesIndex) { - const metricData = response[0].results; - const records = response[1].records; - const jobId = seriesConfigs[seriesIndex].jobId; - const scheduledEvents = response[2].events[jobId]; - const eventDistribution = response[3]; - const chartType = getChartType(seriesConfigs[seriesIndex]); - - // Sort records in ascending time order matching up with chart data - records.sort((recordA, recordB) => { - return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; - }); + // first load and wait for required data, + // only after that trigger data processing and page render. + // TODO - if query returns no results e.g. source data has been deleted, + // display a message saying 'No data between earliest/latest'. + const seriesPromises = seriesConfigs.map(seriesConfig => + Promise.all([ + getMetricData(seriesConfig, chartRange), + getRecordsForCriteria(seriesConfig, chartRange), + getScheduledEvents(seriesConfig, chartRange), + getEventDistribution(seriesConfig, chartRange), + ]) + ); + + function processChartData(response, seriesIndex) { + const metricData = response[0].results; + const records = response[1].records; + const jobId = seriesConfigs[seriesIndex].jobId; + const scheduledEvents = response[2].events[jobId]; + const eventDistribution = response[3]; + const chartType = getChartType(seriesConfigs[seriesIndex]); + + // Sort records in ascending time order matching up with chart data + records.sort((recordA, recordB) => { + return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; + }); - // Return dataset in format used by the chart. - // i.e. array of Objects with keys date (timestamp), value, - // plus anomalyScore for points with anomaly markers. - let chartData = []; - if (metricData !== undefined) { - if (eventDistribution.length > 0 && records.length > 0) { - const filterField = records[0].by_field_value || records[0].over_field_value; - chartData = eventDistribution.filter(d => d.entity !== filterField); - _.map(metricData, (value, time) => { - // The filtering for rare/event_distribution charts needs to be handled - // differently because of how the source data is structured. - // For rare chart values we are only interested wether a value is either `0` or not, - // `0` acts like a flag in the chart whether to display the dot/marker. - // All other charts (single metric, population) are metric based and with - // those a value of `null` acts as the flag to hide a data point. - if ( - (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || - (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) - ) { - chartData.push({ - date: +time, - value: value, - entity: filterField, - }); - } - }); - } else { - chartData = _.map(metricData, (value, time) => ({ - date: +time, - value: value, - })); - } + // Return dataset in format used by the chart. + // i.e. array of Objects with keys date (timestamp), value, + // plus anomalyScore for points with anomaly markers. + let chartData = []; + if (metricData !== undefined) { + if (eventDistribution.length > 0 && records.length > 0) { + const filterField = records[0].by_field_value || records[0].over_field_value; + chartData = eventDistribution.filter(d => d.entity !== filterField); + _.map(metricData, (value, time) => { + // The filtering for rare/event_distribution charts needs to be handled + // differently because of how the source data is structured. + // For rare chart values we are only interested wether a value is either `0` or not, + // `0` acts like a flag in the chart whether to display the dot/marker. + // All other charts (single metric, population) are metric based and with + // those a value of `null` acts as the flag to hide a data point. + if ( + (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || + (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) + ) { + chartData.push({ + date: +time, + value: value, + entity: filterField, + }); + } + }); + } else { + chartData = _.map(metricData, (value, time) => ({ + date: +time, + value: value, + })); } + } - // Iterate through the anomaly records, adding anomalyScore properties - // to the chartData entries for anomalous buckets. - const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); - _.each(records, record => { - // Look for a chart point with the same time as the record. - // If none found, insert a point for anomalies due to a gap in the data. - const recordTime = record[ML_TIME_FIELD_NAME]; - let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); - if (chartPoint === undefined) { - chartPoint = { date: new Date(recordTime), value: null }; - chartData.push(chartPoint); - } + // Iterate through the anomaly records, adding anomalyScore properties + // to the chartData entries for anomalous buckets. + const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); + _.each(records, record => { + // Look for a chart point with the same time as the record. + // If none found, insert a point for anomalies due to a gap in the data. + const recordTime = record[ML_TIME_FIELD_NAME]; + let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); + if (chartPoint === undefined) { + chartPoint = { date: new Date(recordTime), value: null }; + chartData.push(chartPoint); + } - chartPoint.anomalyScore = record.record_score; + chartPoint.anomalyScore = record.record_score; - if (record.actual !== undefined) { - chartPoint.actual = record.actual; - chartPoint.typical = record.typical; - } else { - const causes = _.get(record, 'causes', []); - if (causes.length > 0) { - chartPoint.byFieldName = record.by_field_name; - chartPoint.numberOfCauses = causes.length; - if (causes.length === 1) { - // If only a single cause, copy actual and typical values to the top level. - const cause = _.first(record.causes); - chartPoint.actual = cause.actual; - chartPoint.typical = cause.typical; - } + if (record.actual !== undefined) { + chartPoint.actual = record.actual; + chartPoint.typical = record.typical; + } else { + const causes = _.get(record, 'causes', []); + if (causes.length > 0) { + chartPoint.byFieldName = record.by_field_name; + chartPoint.numberOfCauses = causes.length; + if (causes.length === 1) { + // If only a single cause, copy actual and typical values to the top level. + const cause = _.first(record.causes); + chartPoint.actual = cause.actual; + chartPoint.typical = cause.typical; } } + } + + if (record.multi_bucket_impact !== undefined) { + chartPoint.multiBucketImpact = record.multi_bucket_impact; + } + }); - if (record.multi_bucket_impact !== undefined) { - chartPoint.multiBucketImpact = record.multi_bucket_impact; + // Add a scheduledEvents property to any points in the chart data set + // which correspond to times of scheduled events for the job. + if (scheduledEvents !== undefined) { + _.each(scheduledEvents, (events, time) => { + const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); + if (chartPoint !== undefined) { + // Note if the scheduled event coincides with an absence of the underlying metric data, + // we don't worry about plotting the event. + chartPoint.scheduledEvents = events; } }); + } - // Add a scheduledEvents property to any points in the chart data set - // which correspond to times of scheduled events for the job. - if (scheduledEvents !== undefined) { - _.each(scheduledEvents, (events, time) => { - const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); - if (chartPoint !== undefined) { - // Note if the scheduled event coincides with an absence of the underlying metric data, - // we don't worry about plotting the event. - chartPoint.scheduledEvents = events; - } - }); - } + return chartData; + } - return chartData; + function getChartDataForPointSearch(chartData, record, chartType) { + if ( + chartType === CHART_TYPE.EVENT_DISTRIBUTION || + chartType === CHART_TYPE.POPULATION_DISTRIBUTION + ) { + return chartData.filter(d => { + return d.entity === (record && (record.by_field_value || record.over_field_value)); + }); } - function getChartDataForPointSearch(chartData, record, chartType) { - if ( - chartType === CHART_TYPE.EVENT_DISTRIBUTION || - chartType === CHART_TYPE.POPULATION_DISTRIBUTION - ) { - return chartData.filter(d => { - return d.entity === (record && (record.by_field_value || record.over_field_value)); - }); - } + return chartData; + } - return chartData; - } + function findChartPointForTime(chartData, time) { + return chartData.find(point => point.date === time); + } - function findChartPointForTime(chartData, time) { - return chartData.find(point => point.date === time); + Promise.all(seriesPromises) + .then(response => { + // calculate an overall min/max for all series + const processedData = response.map(processChartData); + const allDataPoints = _.reduce( + processedData, + (datapoints, series) => { + _.each(series, d => datapoints.push(d)); + return datapoints; + }, + [] + ); + const overallChartLimits = chartLimits(allDataPoints); + + data.seriesToPlot = response.map((d, i) => ({ + ...seriesConfigs[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: earliestMs, + selectedLatest: latestMs, + chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]), + })); + explorerService.setCharts({ ...data }); + }) + .catch(error => { + console.error(error); + }); +}; + +function processRecordsForDisplay(anomalyRecords) { + // Aggregate the anomaly data by detector, and entity (by/over/partition). + if (anomalyRecords.length === 0) { + return []; + } + + // Aggregate by job, detector, and analysis fields (partition, by, over). + const aggregatedData = {}; + _.each(anomalyRecords, record => { + // Check if we can plot a chart for this record, depending on whether the source data + // is chartable, and if model plot is enabled for the job. + const job = mlJobService.getJob(record.job_id); + let isChartable = isSourceDataChartableForDetector(job, record.detector_index); + if (isChartable === false) { + // Check if model plot is enabled for this job. + // Need to check the entity fields for the record in case the model plot config has a terms list. + const entityFields = getEntityFieldList(record); + isChartable = isModelPlotEnabled(job, record.detector_index, entityFields); } - Promise.all(seriesPromises) - .then(response => { - // calculate an overall min/max for all series - const processedData = response.map(processChartData); - const allDataPoints = _.reduce( - processedData, - (datapoints, series) => { - _.each(series, d => datapoints.push(d)); - return datapoints; - }, - [] - ); - const overallChartLimits = chartLimits(allDataPoints); - - data.seriesToPlot = response.map((d, i) => ({ - ...seriesConfigs[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: earliestMs, - selectedLatest: latestMs, - chartLimits: USE_OVERALL_CHART_LIMITS - ? overallChartLimits - : chartLimits(processedData[i]), - })); - callback(data); - }) - .catch(error => { - console.error(error); - }); - }; + if (isChartable === false) { + return; + } + const jobId = record.job_id; + if (aggregatedData[jobId] === undefined) { + aggregatedData[jobId] = {}; + } + const detectorsForJob = aggregatedData[jobId]; - function processRecordsForDisplay(anomalyRecords) { - // Aggregate the anomaly data by detector, and entity (by/over/partition). - if (anomalyRecords.length === 0) { - return []; + const detectorIndex = record.detector_index; + if (detectorsForJob[detectorIndex] === undefined) { + detectorsForJob[detectorIndex] = {}; } - // Aggregate by job, detector, and analysis fields (partition, by, over). - const aggregatedData = {}; - _.each(anomalyRecords, record => { - // Check if we can plot a chart for this record, depending on whether the source data - // is chartable, and if model plot is enabled for the job. - const job = mlJobService.getJob(record.job_id); - let isChartable = isSourceDataChartableForDetector(job, record.detector_index); - if (isChartable === false) { - // Check if model plot is enabled for this job. - // Need to check the entity fields for the record in case the model plot config has a terms list. - const entityFields = getEntityFieldList(record); - isChartable = isModelPlotEnabled(job, record.detector_index, entityFields); - } + // TODO - work out how best to display results from detectors with just an over field. + const firstFieldName = + record.partition_field_name || record.by_field_name || record.over_field_name; + const firstFieldValue = + record.partition_field_value || record.by_field_value || record.over_field_value; + if (firstFieldName !== undefined) { + const groupsForDetector = detectorsForJob[detectorIndex]; - if (isChartable === false) { - return; + if (groupsForDetector[firstFieldName] === undefined) { + groupsForDetector[firstFieldName] = {}; } - const jobId = record.job_id; - if (aggregatedData[jobId] === undefined) { - aggregatedData[jobId] = {}; + const valuesForGroup = groupsForDetector[firstFieldName]; + if (valuesForGroup[firstFieldValue] === undefined) { + valuesForGroup[firstFieldValue] = {}; } - const detectorsForJob = aggregatedData[jobId]; - const detectorIndex = record.detector_index; - if (detectorsForJob[detectorIndex] === undefined) { - detectorsForJob[detectorIndex] = {}; - } + const dataForGroupValue = valuesForGroup[firstFieldValue]; - // TODO - work out how best to display results from detectors with just an over field. - const firstFieldName = - record.partition_field_name || record.by_field_name || record.over_field_name; - const firstFieldValue = - record.partition_field_value || record.by_field_value || record.over_field_value; - if (firstFieldName !== undefined) { - const groupsForDetector = detectorsForJob[detectorIndex]; - - if (groupsForDetector[firstFieldName] === undefined) { - groupsForDetector[firstFieldName] = {}; - } - const valuesForGroup = groupsForDetector[firstFieldName]; - if (valuesForGroup[firstFieldValue] === undefined) { - valuesForGroup[firstFieldValue] = {}; - } - - const dataForGroupValue = valuesForGroup[firstFieldValue]; - - let isSecondSplit = false; - if (record.partition_field_name !== undefined) { - const splitFieldName = record.over_field_name || record.by_field_name; - if (splitFieldName !== undefined) { - isSecondSplit = true; - } + let isSecondSplit = false; + if (record.partition_field_name !== undefined) { + const splitFieldName = record.over_field_name || record.by_field_name; + if (splitFieldName !== undefined) { + isSecondSplit = true; } + } - if (isSecondSplit === false) { - if (dataForGroupValue.maxScoreRecord === undefined) { + if (isSecondSplit === false) { + if (dataForGroupValue.maxScoreRecord === undefined) { + dataForGroupValue.maxScore = record.record_score; + dataForGroupValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForGroupValue.maxScore) { dataForGroupValue.maxScore = record.record_score; dataForGroupValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForGroupValue.maxScore) { - dataForGroupValue.maxScore = record.record_score; - dataForGroupValue.maxScoreRecord = record; - } } - } else { - // Aggregate another level for the over or by field. - const secondFieldName = record.over_field_name || record.by_field_name; - const secondFieldValue = record.over_field_value || record.by_field_value; + } + } else { + // Aggregate another level for the over or by field. + const secondFieldName = record.over_field_name || record.by_field_name; + const secondFieldValue = record.over_field_value || record.by_field_value; - if (dataForGroupValue[secondFieldName] === undefined) { - dataForGroupValue[secondFieldName] = {}; - } + if (dataForGroupValue[secondFieldName] === undefined) { + dataForGroupValue[secondFieldName] = {}; + } - const splitsForGroup = dataForGroupValue[secondFieldName]; - if (splitsForGroup[secondFieldValue] === undefined) { - splitsForGroup[secondFieldValue] = {}; - } + const splitsForGroup = dataForGroupValue[secondFieldName]; + if (splitsForGroup[secondFieldValue] === undefined) { + splitsForGroup[secondFieldValue] = {}; + } - const dataForSplitValue = splitsForGroup[secondFieldValue]; - if (dataForSplitValue.maxScoreRecord === undefined) { + const dataForSplitValue = splitsForGroup[secondFieldValue]; + if (dataForSplitValue.maxScoreRecord === undefined) { + dataForSplitValue.maxScore = record.record_score; + dataForSplitValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForSplitValue.maxScore) { dataForSplitValue.maxScore = record.record_score; dataForSplitValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForSplitValue.maxScore) { - dataForSplitValue.maxScore = record.record_score; - dataForSplitValue.maxScoreRecord = record; - } } } + } + } else { + // Detector with no partition or by field. + const dataForDetector = detectorsForJob[detectorIndex]; + if (dataForDetector.maxScoreRecord === undefined) { + dataForDetector.maxScore = record.record_score; + dataForDetector.maxScoreRecord = record; } else { - // Detector with no partition or by field. - const dataForDetector = detectorsForJob[detectorIndex]; - if (dataForDetector.maxScoreRecord === undefined) { + if (record.record_score > dataForDetector.maxScore) { dataForDetector.maxScore = record.record_score; dataForDetector.maxScoreRecord = record; - } else { - if (record.record_score > dataForDetector.maxScore) { - dataForDetector.maxScore = record.record_score; - dataForDetector.maxScoreRecord = record; - } } } - }); - - console.log('explorer charts aggregatedData is:', aggregatedData); - let recordsForSeries = []; - // Convert to an array of the records with the highest record_score per unique series. - _.each(aggregatedData, detectorsForJob => { - _.each(detectorsForJob, groupsForDetector => { - if (groupsForDetector.maxScoreRecord !== undefined) { - // Detector with no partition / by field. - recordsForSeries.push(groupsForDetector.maxScoreRecord); - } else { - _.each(groupsForDetector, valuesForGroup => { - _.each(valuesForGroup, dataForGroupValue => { - if (dataForGroupValue.maxScoreRecord !== undefined) { - recordsForSeries.push(dataForGroupValue.maxScoreRecord); - } else { - // Second level of aggregation for partition and by/over. - _.each(dataForGroupValue, splitsForGroup => { - _.each(splitsForGroup, dataForSplitValue => { - recordsForSeries.push(dataForSplitValue.maxScoreRecord); - }); + } + }); + + console.log('explorer charts aggregatedData is:', aggregatedData); + let recordsForSeries = []; + // Convert to an array of the records with the highest record_score per unique series. + _.each(aggregatedData, detectorsForJob => { + _.each(detectorsForJob, groupsForDetector => { + if (groupsForDetector.maxScoreRecord !== undefined) { + // Detector with no partition / by field. + recordsForSeries.push(groupsForDetector.maxScoreRecord); + } else { + _.each(groupsForDetector, valuesForGroup => { + _.each(valuesForGroup, dataForGroupValue => { + if (dataForGroupValue.maxScoreRecord !== undefined) { + recordsForSeries.push(dataForGroupValue.maxScoreRecord); + } else { + // Second level of aggregation for partition and by/over. + _.each(dataForGroupValue, splitsForGroup => { + _.each(splitsForGroup, dataForSplitValue => { + recordsForSeries.push(dataForSplitValue.maxScoreRecord); }); - } - }); + }); + } }); - } - }); + }); + } }); - recordsForSeries = _.sortBy(recordsForSeries, 'record_score').reverse(); + }); + recordsForSeries = _.sortBy(recordsForSeries, 'record_score').reverse(); - return recordsForSeries; - } + return recordsForSeries; +} - function calculateChartRange( - seriesConfigs, - earliestMs, - latestMs, - chartWidth, - recordsToPlot, - timeFieldName - ) { - let tooManyBuckets = false; - // Calculate the time range for the charts. - // Fit in as many points in the available container width plotted at the job bucket span. - const midpointMs = Math.ceil((earliestMs + latestMs) / 2); - const maxBucketSpanMs = - Math.max.apply(null, _.pluck(seriesConfigs, 'bucketSpanSeconds')) * 1000; - - const pointsToPlotFullSelection = Math.ceil((latestMs - earliestMs) / maxBucketSpanMs); - - // Optimally space points 5px apart. - const optimumPointSpacing = 5; - const optimumNumPoints = chartWidth / optimumPointSpacing; - - // Increase actual number of points if we can't plot the selected range - // at optimal point spacing. - const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); - const halfPoints = Math.ceil(plotPoints / 2); - let chartRange = { - min: midpointMs - halfPoints * maxBucketSpanMs, - max: midpointMs + halfPoints * maxBucketSpanMs, - }; - - if (plotPoints > CHART_MAX_POINTS) { - tooManyBuckets = true; - // For each series being plotted, display the record with the highest score if possible. - const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS; - let minMs = recordsToPlot[0][timeFieldName]; - let maxMs = recordsToPlot[0][timeFieldName]; - - _.each(recordsToPlot, record => { - const diffMs = maxMs - minMs; - if (diffMs < maxTimeSpan) { - const recordTime = record[timeFieldName]; - if (recordTime < minMs) { - if (maxMs - recordTime <= maxTimeSpan) { - minMs = recordTime; - } - } +function calculateChartRange( + seriesConfigs, + earliestMs, + latestMs, + chartWidth, + recordsToPlot, + timeFieldName +) { + let tooManyBuckets = false; + // Calculate the time range for the charts. + // Fit in as many points in the available container width plotted at the job bucket span. + const midpointMs = Math.ceil((earliestMs + latestMs) / 2); + const maxBucketSpanMs = Math.max.apply(null, _.pluck(seriesConfigs, 'bucketSpanSeconds')) * 1000; + + const pointsToPlotFullSelection = Math.ceil((latestMs - earliestMs) / maxBucketSpanMs); + + // Optimally space points 5px apart. + const optimumPointSpacing = 5; + const optimumNumPoints = chartWidth / optimumPointSpacing; + + // Increase actual number of points if we can't plot the selected range + // at optimal point spacing. + const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); + const halfPoints = Math.ceil(plotPoints / 2); + let chartRange = { + min: midpointMs - halfPoints * maxBucketSpanMs, + max: midpointMs + halfPoints * maxBucketSpanMs, + }; - if (recordTime > maxMs) { - if (recordTime - minMs <= maxTimeSpan) { - maxMs = recordTime; - } + if (plotPoints > CHART_MAX_POINTS) { + tooManyBuckets = true; + // For each series being plotted, display the record with the highest score if possible. + const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS; + let minMs = recordsToPlot[0][timeFieldName]; + let maxMs = recordsToPlot[0][timeFieldName]; + + _.each(recordsToPlot, record => { + const diffMs = maxMs - minMs; + if (diffMs < maxTimeSpan) { + const recordTime = record[timeFieldName]; + if (recordTime < minMs) { + if (maxMs - recordTime <= maxTimeSpan) { + minMs = recordTime; } } - }); - if (maxMs - minMs < maxTimeSpan) { - // Expand out to cover as much as the requested time span as possible. - minMs = Math.max(earliestMs, minMs - maxTimeSpan); - maxMs = Math.min(latestMs, maxMs + maxTimeSpan); + if (recordTime > maxMs) { + if (recordTime - minMs <= maxTimeSpan) { + maxMs = recordTime; + } + } } + }); - chartRange = { min: minMs, max: maxMs }; + if (maxMs - minMs < maxTimeSpan) { + // Expand out to cover as much as the requested time span as possible. + minMs = Math.max(earliestMs, minMs - maxTimeSpan); + maxMs = Math.min(latestMs, maxMs + maxTimeSpan); } - return { - chartRange, - tooManyBuckets, - }; + chartRange = { min: minMs, max: maxMs }; } - return anomalyDataChange; + return { + chartRange, + tooManyBuckets, + }; } diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js index 483a359f98e5b9..fbbf5eb3240952 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js @@ -102,119 +102,79 @@ jest.mock('ui/chrome', () => ({ }), })); -import { - explorerChartsContainerServiceFactory, - getDefaultChartsData, -} from './explorer_charts_container_service'; +jest.mock('../explorer_dashboard_service', () => ({ + explorerService: { + setCharts: jest.fn(), + }, +})); -describe('explorerChartsContainerService', () => { - test('Initialize factory', done => { - explorerChartsContainerServiceFactory(callback); +import { anomalyDataChange, getDefaultChartsData } from './explorer_charts_container_service'; +import { explorerService } from '../explorer_dashboard_service'; - function callback(data) { - expect(data).toEqual(getDefaultChartsData()); - done(); - } +describe('explorerChartsContainerService', () => { + afterEach(() => { + explorerService.setCharts.mockClear(); }); test('call anomalyChangeListener with empty series config', done => { - // callback will be called multiple times. - // the callbackData array contains the expected data values for each consecutive call. - const callbackData = []; - callbackData.push(getDefaultChartsData()); - callbackData.push({ - ...getDefaultChartsData(), - chartsPerRow: 2, + anomalyDataChange([], 1486656000000, 1486670399999); + + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(1); + expect(explorerService.setCharts.mock.calls[0][0]).toStrictEqual({ + ...getDefaultChartsData(), + chartsPerRow: 2, + }); + done(); }); - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); - - anomalyDataChangeListener([], 1486656000000, 1486670399999); - - function callback(data) { - if (callbackData.length > 0) { - expect(data).toEqual({ - ...callbackData.shift(), - }); - } - if (callbackData.length === 0) { - done(); - } - } }); test('call anomalyChangeListener with actual series config', done => { - let callbackCount = 0; - const expectedTestCount = 3; - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); + anomalyDataChange(mockAnomalyChartRecords, 1486656000000, 1486670399999); - anomalyDataChangeListener(mockAnomalyChartRecords, 1486656000000, 1486670399999); - - function callback(data) { - callbackCount++; - expect(data).toMatchSnapshot(); - if (callbackCount === expectedTestCount) { - done(); - } - } + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(2); + expect(explorerService.setCharts.mock.calls[0][0]).toMatchSnapshot(); + expect(explorerService.setCharts.mock.calls[1][0]).toMatchSnapshot(); + done(); + }); }); test('filtering should skip values of null', done => { - let callbackCount = 0; - const expectedTestCount = 3; - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); - const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords).map(d => { d.job_id = 'mock-job-id-distribution'; return d; }); - anomalyDataChangeListener(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); + anomalyDataChange(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); - function callback(data) { - callbackCount++; + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(2); + expect(explorerService.setCharts.mock.calls[0][0].seriesToPlot.length).toBe(1); + expect(explorerService.setCharts.mock.calls[1][0].seriesToPlot.length).toBe(1); - if (callbackCount === 1) { - expect(data.seriesToPlot).toHaveLength(0); - } - if (callbackCount === 3) { - expect(data.seriesToPlot).toHaveLength(1); - - // the mock source dataset has a length of 115. one data point has a value of `null`, - // and another one `0`. the received dataset should have a length of 114, - // it should remove the datapoint with `null` and keep the one with `0`. - const chartData = data.seriesToPlot[0].chartData; - expect(chartData).toHaveLength(114); - expect(chartData.filter(d => d.value === 0)).toHaveLength(1); - expect(chartData.filter(d => d.value === null)).toHaveLength(0); - } - if (callbackCount === expectedTestCount) { - done(); - } - } + // the mock source dataset has a length of 115. one data point has a value of `null`, + // and another one `0`. the received dataset should have a length of 114, + // it should remove the datapoint with `null` and keep the one with `0`. + const chartData = explorerService.setCharts.mock.calls[1][0].seriesToPlot[0].chartData; + expect(chartData).toHaveLength(114); + expect(chartData.filter(d => d.value === 0)).toHaveLength(1); + expect(chartData.filter(d => d.value === null)).toHaveLength(0); + done(); + }); }); test('field value with trailing dot should not throw an error', done => { - let callbackCount = 0; - const expectedTestCount = 3; - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); - const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords); mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; expect(() => { - anomalyDataChangeListener(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); + anomalyDataChange(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); }).not.toThrow(); - function callback() { - callbackCount++; - - if (callbackCount === expectedTestCount) { - done(); - } - } + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(2); + done(); + }); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts index 66cd98f7ebe29a..b084f503272ccf 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts @@ -17,24 +17,15 @@ export const DRAG_SELECT_ACTION = { }; export const EXPLORER_ACTION = { - APP_STATE_SET: 'appStateSet', - APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: 'appStateClearInfluencerFilterSettings', - APP_STATE_CLEAR_SELECTION: 'appStateClearSelection', - APP_STATE_SAVE_SELECTION: 'appStateSaveSelection', - APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: 'appStateSaveViewBySwimlaneFieldName', - APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: 'appStateSaveInfluencerFilterSettings', CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings', CLEAR_JOBS: 'clearJobs', - CLEAR_SELECTION: 'clearSelection', - INITIALIZE: 'initialize', JOB_SELECTION_CHANGE: 'jobSelectionChange', - LOAD_JOBS: 'loadJobs', - RESET: 'reset', SET_BOUNDS: 'setBounds', SET_CHARTS: 'setCharts', + SET_EXPLORER_DATA: 'setExplorerData', + SET_FILTER_DATA: 'setFilterData', SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings', SET_SELECTED_CELLS: 'setSelectedCells', - SET_STATE: 'setState', SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth', SET_SWIMLANE_LIMIT: 'setSwimlaneLimit', SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName', diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index 713857835b3b9a..89e1a908b1ecc6 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -9,30 +9,25 @@ * components in the Explorer dashboard. */ -import { isEqual, pick } from 'lodash'; +import { isEqual } from 'lodash'; -import { from, isObservable, BehaviorSubject, Observable, Subject } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, pairwise, scan } from 'rxjs/operators'; +import { from, isObservable, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, flatMap, map, scan } from 'rxjs/operators'; import { DeepPartial } from '../../../common/types/common'; -import { jobSelectionActionCreator, loadExplorerData } from './actions'; +import { jobSelectionActionCreator } from './actions'; import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; import { EXPLORER_ACTION } from './explorer_constants'; -import { RestoredAppState, SelectedCells, TimeRangeBounds } from './explorer_utils'; -import { - explorerReducer, - getExplorerDefaultState, - ExplorerAppState, - ExplorerState, -} from './reducers'; +import { AppStateSelectedCells, TimeRangeBounds } from './explorer_utils'; +import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers'; export const ALLOW_CELL_RANGE_SELECTION = true; export const dragSelect$ = new Subject(); type ExplorerAction = Action | Observable; -const explorerAction$ = new BehaviorSubject({ type: EXPLORER_ACTION.RESET }); +export const explorerAction$ = new Subject(); export type ActionPayload = any; @@ -51,94 +46,79 @@ const explorerFilteredAction$ = explorerAction$.pipe( // applies action and returns state const explorerState$: Observable = explorerFilteredAction$.pipe( - scan(explorerReducer, getExplorerDefaultState()), - pairwise(), - map(([prev, curr]) => { - if ( - curr.selectedJobs !== null && - curr.bounds !== undefined && - !isEqual(getCompareState(prev), getCompareState(curr)) - ) { - explorerAction$.next(loadExplorerData(curr).pipe(map(d => setStateActionCreator(d)))); - } - return curr; - }) + scan(explorerReducer, getExplorerDefaultState()) ); +interface ExplorerAppState { + mlExplorerSwimlane: { + selectedType?: string; + selectedLanes?: string[]; + selectedTimes?: number[]; + showTopFieldValues?: boolean; + viewByFieldName?: string; + }; + mlExplorerFilter: { + influencersFilterQuery?: unknown; + filterActive?: boolean; + filteredFields?: string[]; + queryString?: string; + }; +} + const explorerAppState$: Observable = explorerState$.pipe( - map((state: ExplorerState) => state.appState), + map( + (state: ExplorerState): ExplorerAppState => { + const appState: ExplorerAppState = { + mlExplorerFilter: {}, + mlExplorerSwimlane: {}, + }; + + if (state.selectedCells !== undefined) { + const swimlaneSelectedCells = state.selectedCells; + appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; + appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; + appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; + appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; + } + + if (state.viewBySwimlaneFieldName !== undefined) { + appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName; + } + + if (state.filterActive) { + appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; + appState.mlExplorerFilter.filterActive = state.filterActive; + appState.mlExplorerFilter.filteredFields = state.filteredFields; + appState.mlExplorerFilter.queryString = state.queryString; + } + + return appState; + } + ), distinctUntilChanged(isEqual) ); -function getCompareState(state: ExplorerState) { - return pick(state, [ - 'bounds', - 'filterActive', - 'filteredFields', - 'influencersFilterQuery', - 'isAndOperator', - 'noInfluencersConfigured', - 'selectedCells', - 'selectedJobs', - 'swimlaneContainerWidth', - 'swimlaneLimit', - 'tableInterval', - 'tableSeverity', - 'viewBySwimlaneFieldName', - ]); -} - -export const setStateActionCreator = (payload: DeepPartial) => ({ - type: EXPLORER_ACTION.SET_STATE, +const setExplorerDataActionCreator = (payload: DeepPartial) => ({ + type: EXPLORER_ACTION.SET_EXPLORER_DATA, + payload, +}); +const setFilterDataActionCreator = (payload: DeepPartial) => ({ + type: EXPLORER_ACTION.SET_FILTER_DATA, payload, }); - -interface AppStateSelection { - type: string; - lanes: string[]; - times: number[]; - showTopFieldValues: boolean; - viewByFieldName: string; -} // Export observable state and action dispatchers as service export const explorerService = { appState$: explorerAppState$, state$: explorerState$, - appStateClearSelection: () => { - explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION }); - }, - appStateSaveSelection: (payload: AppStateSelection) => { - explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_SAVE_SELECTION, payload }); - }, clearInfluencerFilterSettings: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS }); }, clearJobs: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_JOBS }); }, - clearSelection: () => { - explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_SELECTION }); - }, - updateJobSelection: (selectedJobIds: string[], restoredAppState: RestoredAppState) => { - explorerAction$.next( - jobSelectionActionCreator( - EXPLORER_ACTION.JOB_SELECTION_CHANGE, - selectedJobIds, - restoredAppState - ) - ); - }, - initialize: (selectedJobIds: string[], restoredAppState: RestoredAppState) => { - explorerAction$.next( - jobSelectionActionCreator(EXPLORER_ACTION.INITIALIZE, selectedJobIds, restoredAppState) - ); - }, - reset: () => { - explorerAction$.next({ type: EXPLORER_ACTION.RESET }); - }, - setAppState: (payload: DeepPartial) => { - explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_SET, payload }); + updateJobSelection: (selectedJobIds: string[]) => { + explorerAction$.next(jobSelectionActionCreator(selectedJobIds)); }, setBounds: (payload: TimeRangeBounds) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_BOUNDS, payload }); @@ -152,14 +132,17 @@ export const explorerService = { payload, }); }, - setSelectedCells: (payload: SelectedCells) => { + setSelectedCells: (payload: AppStateSelectedCells | undefined) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_SELECTED_CELLS, payload, }); }, - setState: (payload: DeepPartial) => { - explorerAction$.next(setStateActionCreator(payload)); + setExplorerData: (payload: DeepPartial) => { + explorerAction$.next(setExplorerDataActionCreator(payload)); + }, + setFilterData: (payload: DeepPartial) => { + explorerAction$.next(setFilterDataActionCreator(payload)); }, setSwimlaneContainerWidth: (payload: number) => { explorerAction$.next({ diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts index d7873e6d52d785..0ab75b1db29722 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -11,8 +11,7 @@ import { CombinedJob } from '../jobs/new_job/common/job_creator/configs'; import { TimeBucketsInterval } from '../util/time_buckets'; interface ClearedSelectedAnomaliesState { - anomalyChartRecords: []; - selectedCells: null; + selectedCells: undefined; viewByLoadedForTimeFormatted: null; } @@ -37,7 +36,7 @@ export declare const getDefaultSwimlaneData: () => SwimlaneData; export declare const getInfluencers: (selectedJobs: any[]) => string[]; export declare const getSelectionInfluencers: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, fieldName: string ) => any[]; @@ -47,7 +46,7 @@ interface SelectionTimeRange { } export declare const getSelectionTimeRange: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, interval: number, bounds: TimeRangeBounds ) => SelectionTimeRange; @@ -62,7 +61,7 @@ interface ViewBySwimlaneOptionsArgs { filterActive: boolean; filteredFields: any[]; isAndOperator: boolean; - selectedCells: SelectedCells; + selectedCells: AppStateSelectedCells; selectedJobs: ExplorerJob[]; } @@ -94,7 +93,7 @@ declare interface SwimlaneBounds { } export declare const loadAnnotationsTableData: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, selectedJobs: ExplorerJob[], interval: number, bounds: TimeRangeBounds @@ -109,7 +108,7 @@ export declare interface AnomaliesTableData { } export declare const loadAnomaliesTableData: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, selectedJobs: ExplorerJob[], dateFormatTz: any, interval: number, @@ -125,7 +124,7 @@ export declare const loadDataForCharts: ( earliestMs: number, latestMs: number, influencers: any[], - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, influencersFilterQuery: any ) => Promise; @@ -178,25 +177,17 @@ export declare const loadViewByTopFieldValuesForSelectedTime: ( noInfluencersConfigured: boolean ) => Promise; -declare interface FilterData { +export declare interface FilterData { influencersFilterQuery: any; filterActive: boolean; filteredFields: string[]; queryString: string; } -declare interface SelectedCells { +export declare interface AppStateSelectedCells { type: string; lanes: string[]; times: number[]; showTopFieldValues: boolean; viewByFieldName: string; } - -export declare interface RestoredAppState { - selectedCells?: SelectedCells; - filterData: {} | FilterData; - viewBySwimlaneFieldName: string; -} - -export declare const restoreAppState: (appState: any) => RestoredAppState; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js index b54b691f3aba69..4fb4e7d4df94f2 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js @@ -53,8 +53,7 @@ export function createJobs(jobs) { export function getClearedSelectedAnomaliesState() { return { - anomalyChartRecords: [], - selectedCells: null, + selectedCells: undefined, viewByLoadedForTimeFormatted: null, }; } @@ -195,7 +194,7 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) { let earliestMs = bounds.min.valueOf(); let latestMs = bounds.max.valueOf(); - if (selectedCells !== null && selectedCells.times !== undefined) { + if (selectedCells !== undefined && selectedCells.times !== undefined) { // time property of the cell data is an array, with the elements being // the start times of the first and last cell selected. earliestMs = @@ -212,7 +211,7 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) { export function getSelectionInfluencers(selectedCells, fieldName) { if ( - selectedCells !== null && + selectedCells !== undefined && selectedCells.type !== SWIMLANE_TYPE.OVERALL && selectedCells.viewByFieldName !== undefined && selectedCells.viewByFieldName !== VIEW_BY_JOB_LABEL @@ -346,7 +345,7 @@ export function getViewBySwimlaneOptions({ if (selectedJobIds.length > 1) { // If more than one job selected, default to job ID. viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; - } else if (mlJobService.jobs.length > 0) { + } else if (mlJobService.jobs.length > 0 && selectedJobIds.length > 0) { // For a single job, default to the first partition, over, // by or influencer field of the first selected job. const firstSelectedJob = mlJobService.jobs.find(job => { @@ -525,7 +524,7 @@ export function processViewByResults( export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) { const jobIds = - selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL + selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL ? selectedCells.lanes : selectedJobs.map(d => d.id); const timeRange = getSelectionTimeRange(selectedCells, interval, bounds); @@ -587,7 +586,7 @@ export async function loadAnomaliesTableData( influencersFilterQuery ) { const jobIds = - selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL + selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL ? selectedCells.lanes : selectedJobs.map(d => d.id); const influencers = getSelectionInfluencers(selectedCells, fieldName); @@ -677,7 +676,7 @@ export async function loadDataForCharts( // Just skip doing the request when this function // is called without the minimum required data. if ( - selectedCells === null && + selectedCells === undefined && influencers.length === 0 && influencersFilterQuery === undefined ) { @@ -705,7 +704,7 @@ export async function loadDataForCharts( } if ( - (selectedCells !== null && Object.keys(selectedCells).length > 0) || + (selectedCells !== undefined && Object.keys(selectedCells).length > 0) || influencersFilterQuery !== undefined ) { console.log('Explorer anomaly charts data set:', resp.records); @@ -879,36 +878,3 @@ export async function loadTopInfluencers( } }); } - -export function restoreAppState(appState) { - // Select any jobs set in the global state (i.e. passed in the URL). - let selectedCells; - let filterData = {}; - - // keep swimlane selection, restore selectedCells from AppState - if (appState.mlExplorerSwimlane.selectedType !== undefined) { - selectedCells = { - type: appState.mlExplorerSwimlane.selectedType, - lanes: appState.mlExplorerSwimlane.selectedLanes, - times: appState.mlExplorerSwimlane.selectedTimes, - showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, - viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, - }; - } - - // keep influencers filter selection, restore from AppState - if (appState.mlExplorerFilter.influencersFilterQuery !== undefined) { - filterData = { - influencersFilterQuery: appState.mlExplorerFilter.influencersFilterQuery, - filterActive: appState.mlExplorerFilter.filterActive, - filteredFields: appState.mlExplorerFilter.filteredFields, - queryString: appState.mlExplorerFilter.queryString, - }; - } - - return { - filterData, - selectedCells, - viewBySwimlaneFieldName: appState.mlExplorerSwimlane.viewByFieldName, - }; -} diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts new file mode 100644 index 00000000000000..2b3e1c7bd656f9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.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 { useUrlState } from '../../util/url_state'; +import { SWIMLANE_TYPE } from '../../explorer/explorer_constants'; +import { AppStateSelectedCells } from '../../explorer/explorer_utils'; + +export const useSelectedCells = (): [ + AppStateSelectedCells | undefined, + (swimlaneSelectedCells: AppStateSelectedCells) => void +] => { + const [appState, setAppState] = useUrlState('_a'); + + let selectedCells: AppStateSelectedCells | undefined; + + // keep swimlane selection, restore selectedCells from AppState + if ( + appState && + appState.mlExplorerSwimlane && + appState.mlExplorerSwimlane.selectedType !== undefined + ) { + selectedCells = { + type: appState.mlExplorerSwimlane.selectedType, + lanes: appState.mlExplorerSwimlane.selectedLanes, + times: appState.mlExplorerSwimlane.selectedTimes, + showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, + viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, + }; + } + + const setSelectedCells = (swimlaneSelectedCells: AppStateSelectedCells) => { + const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane }; + if (swimlaneSelectedCells !== undefined) { + swimlaneSelectedCells.showTopFieldValues = false; + + const currentSwimlaneType = selectedCells?.type; + const currentShowTopFieldValues = selectedCells?.showTopFieldValues; + const newSwimlaneType = selectedCells?.type; + + if ( + (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && + newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || + newSwimlaneType === SWIMLANE_TYPE.OVERALL || + currentShowTopFieldValues === true + ) { + swimlaneSelectedCells.showTopFieldValues = true; + } + + mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; + mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; + mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; + mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; + setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + } else { + delete mlExplorerSwimlane.selectedType; + delete mlExplorerSwimlane.selectedLanes; + delete mlExplorerSwimlane.selectedTimes; + delete mlExplorerSwimlane.showTopFieldValues; + setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + } + }; + + return [selectedCells, setSelectedCells]; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts deleted file mode 100644 index 66e00a41a3f313..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts +++ /dev/null @@ -1,89 +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 { cloneDeep } from 'lodash'; - -import { EXPLORER_ACTION } from '../explorer_constants'; -import { Action } from '../explorer_dashboard_service'; - -export interface ExplorerAppState { - mlExplorerSwimlane: { - selectedType?: string; - selectedLanes?: string[]; - selectedTimes?: number[]; - showTopFieldValues?: boolean; - viewByFieldName?: string; - }; - mlExplorerFilter: { - influencersFilterQuery?: unknown; - filterActive?: boolean; - filteredFields?: string[]; - queryString?: string; - }; -} - -export function getExplorerDefaultAppState(): ExplorerAppState { - return { - mlExplorerSwimlane: {}, - mlExplorerFilter: {}, - }; -} - -export const appStateReducer = (state: ExplorerAppState, nextAction: Action) => { - const { type, payload } = nextAction; - - const appState = cloneDeep(state); - - if (appState.mlExplorerSwimlane === undefined) { - appState.mlExplorerSwimlane = {}; - } - if (appState.mlExplorerFilter === undefined) { - appState.mlExplorerFilter = {}; - } - - switch (type) { - case EXPLORER_ACTION.APP_STATE_SET: - return { ...appState, ...payload }; - - case EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION: - delete appState.mlExplorerSwimlane.selectedType; - delete appState.mlExplorerSwimlane.selectedLanes; - delete appState.mlExplorerSwimlane.selectedTimes; - delete appState.mlExplorerSwimlane.showTopFieldValues; - break; - - case EXPLORER_ACTION.APP_STATE_SAVE_SELECTION: - const swimlaneSelectedCells = payload; - appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - appState.mlExplorerSwimlane.viewByFieldName = swimlaneSelectedCells.viewByFieldName; - break; - - case EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: - appState.mlExplorerSwimlane.viewByFieldName = payload.viewBySwimlaneFieldName; - break; - - case EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: - appState.mlExplorerFilter.influencersFilterQuery = payload.influencersFilterQuery; - appState.mlExplorerFilter.filterActive = payload.filterActive; - appState.mlExplorerFilter.filteredFields = payload.filteredFields; - appState.mlExplorerFilter.queryString = payload.queryString; - break; - - case EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: - delete appState.mlExplorerFilter.influencersFilterQuery; - delete appState.mlExplorerFilter.filterActive; - delete appState.mlExplorerFilter.filteredFields; - delete appState.mlExplorerFilter.queryString; - break; - - default: - } - - return appState; -}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts index 28f04bf65634a6..daeb9ae54013c7 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION, SWIMLANE_TYPE } from '../../explorer_constants'; +import { SWIMLANE_TYPE } from '../../explorer_constants'; import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; -import { appStateReducer } from '../app_state_reducer'; - import { ExplorerState } from './state'; interface SwimlanePoint { @@ -21,18 +19,26 @@ interface SwimlanePoint { // If filter is active - selectedCell may not be available due to swimlane view by change to filter fieldName // Ok to keep cellSelection in this case export const checkSelectedCells = (state: ExplorerState) => { - const { filterActive, selectedCells, viewBySwimlaneData, viewBySwimlaneDataLoading } = state; - - if (viewBySwimlaneDataLoading) { + const { + filterActive, + loading, + selectedCells, + viewBySwimlaneData, + viewBySwimlaneDataLoading, + } = state; + + if (loading || viewBySwimlaneDataLoading) { return {}; } let clearSelection = false; if ( + selectedCells !== undefined && selectedCells !== null && selectedCells.type === SWIMLANE_TYPE.VIEW_BY && viewBySwimlaneData !== undefined && - viewBySwimlaneData.points !== undefined + viewBySwimlaneData.points !== undefined && + viewBySwimlaneData.points.length > 0 ) { clearSelection = filterActive === false && @@ -49,9 +55,6 @@ export const checkSelectedCells = (state: ExplorerState) => { if (clearSelection === true) { return { - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), ...getClearedSelectedAnomaliesState(), }; } diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts index 29c077a5cba434..1614da14e355a4 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts @@ -4,24 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION } from '../../explorer_constants'; import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; -import { appStateReducer } from '../app_state_reducer'; - import { ExplorerState } from './state'; export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerState { - const appStateClearInfluencer = appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS, - }); - const appStateClearSelection = appStateReducer(appStateClearInfluencer, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }); - return { ...state, - appState: appStateClearSelection, filterActive: false, filteredFields: [], influencersFilterQuery: undefined, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts deleted file mode 100644 index 8536c8f3e542ee..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ActionPayload } from '../../explorer_dashboard_service'; -import { getInfluencers } from '../../explorer_utils'; - -import { getIndexPattern } from './get_index_pattern'; -import { ExplorerState } from './state'; - -export const initialize = (state: ExplorerState, payload: ActionPayload): ExplorerState => { - const { selectedCells, selectedJobs, viewBySwimlaneFieldName, filterData } = payload; - let currentSelectedCells = state.selectedCells; - let currentviewBySwimlaneFieldName = state.viewBySwimlaneFieldName; - - if (viewBySwimlaneFieldName !== undefined) { - currentviewBySwimlaneFieldName = viewBySwimlaneFieldName; - } - - if (selectedCells !== undefined && currentSelectedCells === null) { - currentSelectedCells = selectedCells; - } - - return { - ...state, - indexPattern: getIndexPattern(selectedJobs), - noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, - selectedCells: currentSelectedCells, - selectedJobs, - viewBySwimlaneFieldName: currentviewBySwimlaneFieldName, - ...(filterData.influencersFilterQuery !== undefined ? { ...filterData } : {}), - }; -}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts index 9fe8ebbb2c481c..a26c0564c6b16d 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts @@ -4,27 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; import { ActionPayload } from '../../explorer_dashboard_service'; -import { - getClearedSelectedAnomaliesState, - getDefaultSwimlaneData, - getInfluencers, -} from '../../explorer_utils'; - -import { appStateReducer } from '../app_state_reducer'; +import { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils'; import { getIndexPattern } from './get_index_pattern'; -import { getExplorerDefaultState, ExplorerState } from './state'; +import { ExplorerState } from './state'; export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload): ExplorerState => { const { selectedJobs } = payload; const stateUpdate: ExplorerState = { ...state, - appState: appStateReducer(getExplorerDefaultState().appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), - ...getClearedSelectedAnomaliesState(), noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, @@ -32,9 +21,6 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) // clear filter if selected jobs have no influencers if (stateUpdate.noInfluencersConfigured === true) { - stateUpdate.appState = appStateReducer(stateUpdate.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS, - }); const noFilterState = { filterActive: false, filteredFields: [], @@ -51,11 +37,6 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) stateUpdate.indexPattern = getIndexPattern(selectedJobs); } - if (selectedJobs.length > 1) { - stateUpdate.viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; - return stateUpdate; - } - stateUpdate.loading = true; return stateUpdate; }; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index 1919ce949683f5..c31b26b7adb7b7 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -7,7 +7,7 @@ import { formatHumanReadableDateTime } from '../../../util/date_utils'; import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service'; -import { EXPLORER_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; +import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; import { Action } from '../../explorer_dashboard_service'; import { getClearedSelectedAnomaliesState, @@ -16,13 +16,11 @@ import { getSwimlaneBucketInterval, getViewBySwimlaneOptions, } from '../../explorer_utils'; -import { appStateReducer } from '../app_state_reducer'; import { checkSelectedCells } from './check_selected_cells'; import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; -import { initialize } from './initialize'; import { jobSelectionChange } from './job_selection_change'; -import { getExplorerDefaultState, ExplorerState } from './state'; +import { ExplorerState } from './state'; import { setInfluencerFilterSettings } from './set_influencer_filter_settings'; import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; @@ -40,45 +38,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = { ...state, ...getClearedSelectedAnomaliesState(), - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), loading: false, selectedJobs: [], }; break; - case EXPLORER_ACTION.CLEAR_SELECTION: - nextState = { - ...state, - ...getClearedSelectedAnomaliesState(), - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), - }; - break; - - case EXPLORER_ACTION.INITIALIZE: - nextState = initialize(state, payload); - break; - case EXPLORER_ACTION.JOB_SELECTION_CHANGE: nextState = jobSelectionChange(state, payload); break; - case EXPLORER_ACTION.APP_STATE_SET: - case EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION: - case EXPLORER_ACTION.APP_STATE_SAVE_SELECTION: - case EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: - case EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: - case EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: - nextState = { ...state, appState: appStateReducer(state.appState, nextAction) }; - break; - - case EXPLORER_ACTION.RESET: - nextState = getExplorerDefaultState(); - break; - case EXPLORER_ACTION.SET_BOUNDS: nextState = { ...state, bounds: payload }; break; @@ -102,44 +70,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo case EXPLORER_ACTION.SET_SELECTED_CELLS: const selectedCells = payload; - selectedCells.showTopFieldValues = false; - - const currentSwimlaneType = state.selectedCells?.type; - const currentShowTopFieldValues = state.selectedCells?.showTopFieldValues; - const newSwimlaneType = selectedCells?.type; - - if ( - (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && - newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || - newSwimlaneType === SWIMLANE_TYPE.OVERALL || - currentShowTopFieldValues === true - ) { - selectedCells.showTopFieldValues = true; - } - nextState = { ...state, - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_SAVE_SELECTION, - payload, - }), selectedCells, }; break; - case EXPLORER_ACTION.SET_STATE: - if (payload.viewBySwimlaneFieldName) { - nextState = { - ...state, - ...payload, - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME, - payload: { viewBySwimlaneFieldName: payload.viewBySwimlaneFieldName }, - }), - }; - } else { - nextState = { ...state, ...payload }; - } + case EXPLORER_ACTION.SET_EXPLORER_DATA: + case EXPLORER_ACTION.SET_FILTER_DATA: + nextState = { ...state, ...payload }; break; case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH: @@ -157,10 +96,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo case EXPLORER_ACTION.SET_SWIMLANE_LIMIT: nextState = { ...state, - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), - ...getClearedSelectedAnomaliesState(), swimlaneLimit: payload, }; break; @@ -180,9 +115,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = { ...state, ...getClearedSelectedAnomaliesState(), - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), maskAll, viewBySwimlaneFieldName, }; @@ -216,7 +148,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ); // Does a sanity check on the selected `viewBySwimlaneFieldName` - // and return the available `viewBySwimlaneOptions`. + // and returns the available `viewBySwimlaneOptions`. const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = getViewBySwimlaneOptions({ currentViewBySwimlaneFieldName: nextState.viewBySwimlaneFieldName, filterActive: nextState.filterActive, @@ -238,7 +170,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ...nextState, swimlaneBucketInterval, viewByLoadedForTimeFormatted: - selectedCells !== null && selectedCells.showTopFieldValues === true + selectedCells !== undefined && selectedCells.showTopFieldValues === true ? formatHumanReadableDateTime(timerange.earliestMs) : null, viewBySwimlaneFieldName, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts index 76577ae557fe3b..8d083a396582aa 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; +import { VIEW_BY_JOB_LABEL } from '../../explorer_constants'; import { ActionPayload } from '../../explorer_dashboard_service'; -import { appStateReducer } from '../app_state_reducer'; - import { ExplorerState } from './state'; export function setInfluencerFilterSettings( @@ -43,21 +41,8 @@ export function setInfluencerFilterSettings( } } - const appState = appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS, - payload: { - influencersFilterQuery, - filterActive: true, - filteredFields, - queryString, - tableQueryString, - isAndOperator, - }, - }); - return { ...state, - appState, filterActive: true, filteredFields, influencersFilterQuery, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index ce37605c3a926f..0a2dbf5bcff35f 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -15,16 +15,13 @@ import { getDefaultSwimlaneData, AnomaliesTableData, ExplorerJob, + AppStateSelectedCells, SwimlaneData, TimeRangeBounds, } from '../../explorer_utils'; -import { getExplorerDefaultAppState, ExplorerAppState } from '../app_state_reducer'; - export interface ExplorerState { annotationsData: any[]; - anomalyChartRecords: any[]; - appState: ExplorerAppState; bounds: TimeRangeBounds | undefined; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; @@ -40,15 +37,13 @@ export interface ExplorerState { noInfluencersConfigured: boolean; overallSwimlaneData: SwimlaneData; queryString: string; - selectedCells: any; + selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; swimlaneBucketInterval: any; swimlaneContainerWidth: number; swimlaneLimit: number; tableData: AnomaliesTableData; - tableInterval: string; tableQueryString: string; - tableSeverity: number; viewByLoadedForTimeFormatted: string | null; viewBySwimlaneData: SwimlaneData; viewBySwimlaneDataLoading: boolean; @@ -63,8 +58,6 @@ function getDefaultIndexPattern() { export function getExplorerDefaultState(): ExplorerState { return { annotationsData: [], - anomalyChartRecords: [], - appState: getExplorerDefaultAppState(), bounds: undefined, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, @@ -80,7 +73,7 @@ export function getExplorerDefaultState(): ExplorerState { noInfluencersConfigured: true, overallSwimlaneData: getDefaultSwimlaneData(), queryString: '', - selectedCells: null, + selectedCells: undefined, selectedJobs: null, swimlaneBucketInterval: undefined, swimlaneContainerWidth: 0, @@ -92,9 +85,7 @@ export function getExplorerDefaultState(): ExplorerState { jobIds: [], showViewSeriesLink: false, }, - tableInterval: 'auto', tableQueryString: '', - tableSeverity: 0, viewByLoadedForTimeFormatted: null, viewBySwimlaneData: getDefaultSwimlaneData(), viewBySwimlaneDataLoading: false, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts index 98cc07e8f9449f..29787365923c8c 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { appStateReducer, getExplorerDefaultAppState, ExplorerAppState } from './app_state_reducer'; export { explorerReducer, getExplorerDefaultState, diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/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/ml/public/application/explorer/select_limit/index.ts index aec48f4c626ca0..5b7040e5c3606a 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/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 { useSwimlaneLimit, SelectLimit } from './select_limit'; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js deleted file mode 100644 index 5971e7dcc82be6..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * React component for rendering a select element with limit options. - */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiSelect } from '@elastic/eui'; - -import { injectObservablesAsProps } from '../../util/observable_utils'; - -const optionsMap = { - '5': 5, - '10': 10, - '25': 25, - '50': 50, -}; - -const LIMIT_OPTIONS = [ - { val: 5, display: '5' }, - { val: 10, display: '10' }, - { val: 25, display: '25' }, - { val: 50, display: '50' }, -]; - -function optionValueToLimit(value) { - // Get corresponding limit object with required display and val properties from the specified value. - let limit = LIMIT_OPTIONS.find(opt => opt.val === value); - - // Default to 10 if supplied value doesn't map to one of the options. - if (limit === undefined) { - limit = LIMIT_OPTIONS[1]; - } - - return limit; -} - -const EUI_OPTIONS = LIMIT_OPTIONS.map(({ display, val }) => ({ - value: display, - text: val, -})); - -export const limit$ = new BehaviorSubject(LIMIT_OPTIONS[1]); - -class SelectLimitUnwrapped extends Component { - onChange = e => { - const valueDisplay = e.target.value; - const limit = optionValueToLimit(optionsMap[valueDisplay]); - limit$.next(limit); - }; - - render() { - return ( - - ); - } -} - -SelectLimitUnwrapped.propTypes = { - limit: PropTypes.object, -}; - -SelectLimitUnwrapped.defaultProps = { - limit: LIMIT_OPTIONS[1], -}; - -const SelectLimit = injectObservablesAsProps( - { - limit: limit$, - }, - SelectLimitUnwrapped -); - -export { SelectLimit }; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx similarity index 59% rename from x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js rename to x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx index 60543cfad2de45..657f1c6c7af2ed 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx @@ -5,25 +5,27 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; import { SelectLimit } from './select_limit'; +jest.useFakeTimers(); + describe('SelectLimit', () => { test('creates correct initial selected value', () => { const wrapper = shallow(); - const defaultSelectedValue = wrapper.props().limit.display; - expect(defaultSelectedValue).toBe('10'); + expect(wrapper.props().value).toEqual(10); }); test('state for currently selected value is updated correctly on click', () => { const wrapper = shallow(); - const select = wrapper.first().shallow(); + expect(wrapper.props().value).toEqual(10); - const defaultSelectedValue = wrapper.props().limit.display; - expect(defaultSelectedValue).toBe('10'); + act(() => { + wrapper.simulate('change', { target: { value: 25 } }); + }); + wrapper.update(); - select.simulate('change', { target: { value: '25' } }); - const updatedSelectedValue = wrapper.props().limit.display; - expect(updatedSelectedValue).toBe('25'); + expect(wrapper.props().value).toEqual(10); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx new file mode 100644 index 00000000000000..383d07eb7a9f60 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering a select element with limit options. + */ +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Subject } from 'rxjs'; + +import { EuiSelect } from '@elastic/eui'; + +const limitOptions = [5, 10, 25, 50]; + +const euiOptions = limitOptions.map(limit => ({ + value: limit, + text: `${limit}`, +})); + +export const limit$ = new Subject(); +export const defaultLimit = limitOptions[1]; + +export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => { + const limit = useObservable(limit$, defaultLimit); + + return [limit, (newLimit: number) => limit$.next(newLimit)]; +}; + +export const SelectLimit = () => { + const [limit, setLimit] = useSwimlaneLimit(); + + function onChange(e: React.ChangeEvent) { + setLimit(parseInt(e.target.value, 10)); + } + + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js deleted file mode 100644 index dc9d90d3c677e1..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js +++ /dev/null @@ -1,19 +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. - */ - -/* - * AngularJS service for storing limit values in AppState. - */ - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import { subscribeAppStateToObservable } from '../../util/app_state_utils'; -import { limit$ } from './select_limit'; - -module.service('mlSelectLimitService', function(AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlSelectLimit', limit$, () => $rootScope.$applyAsync()); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx index 1b6b91026d6a5a..6aaad5294369b7 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx @@ -4,27 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useEffect } from 'react'; +import moment from 'moment'; +import React, { FC, useEffect, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + import { i18n } from '@kbn/i18n'; -import { decode } from 'rison-node'; -import { Subscription } from 'rxjs'; -// @ts-ignore -import queryString from 'query-string'; import { timefilter } from 'ui/timefilter'; + +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + import { MlRoute, PageLoader, PageProps } from '../router'; +import { useRefresh } from '../use_refresh'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { Explorer } from '../../explorer'; +import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; -import { getExplorerDefaultAppState, ExplorerAppState } from '../../explorer/reducers'; +import { ml } from '../../services/ml_api_service'; +import { useExplorerData } from '../../explorer/actions'; import { explorerService } from '../../explorer/explorer_dashboard_service'; -import { jobSelectServiceFactory } from '../../components/job_selector/job_select_service_utils'; -import { subscribeAppStateToObservable } from '../../util/app_state_utils'; - -import { interval$ } from '../../components/controls/select_interval'; -import { severity$ } from '../../components/controls/select_severity'; -import { showCharts$ } from '../../components/controls/checkbox_showcharts'; +import { getDateFormatTz } from '../../explorer/explorer_utils'; +import { useSwimlaneLimit } from '../../explorer/select_limit'; +import { useJobSelection } from '../../components/job_selector/use_job_selection'; +import { useShowCharts } from '../../components/controls/checkbox_showcharts'; +import { useTableInterval } from '../../components/controls/select_interval'; +import { useTableSeverity } from '../../components/controls/select_severity'; +import { useUrlState } from '../../util/url_state'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; const breadcrumbs = [ @@ -44,111 +50,140 @@ export const explorerRoute: MlRoute = { breadcrumbs, }; -const PageWrapper: FC = ({ location, config, deps }) => { - const { index } = queryString.parse(location.search); - const { context } = useResolver(index, undefined, config, { +const PageWrapper: FC = ({ config, deps }) => { + const { context, results } = useResolver(undefined, undefined, config, { ...basicResolvers(deps), jobs: mlJobService.loadJobsWrapper, + jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()), }); - const { _a, _g } = queryString.parse(location.search); - let appState: any = {}; - let globalState: any = {}; - try { - appState = decode(_a); - globalState = decode(_g); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Could not parse global or app state'); - } - - if (appState.mlExplorerSwimlane === undefined) { - appState.mlExplorerSwimlane = {}; - } - - if (appState.mlExplorerFilter === undefined) { - appState.mlExplorerFilter = {}; - } - - appState.fetch = () => {}; - appState.on = () => {}; - appState.off = () => {}; - appState.save = () => {}; - globalState.fetch = () => {}; - globalState.on = () => {}; - globalState.off = () => {}; - globalState.save = () => {}; return ( - + ); }; -class AppState { - fetch() {} - on() {} - off() {} - save() {} +interface ExplorerUrlStateManagerProps { + jobsWithTimeRange: MlJobWithTimeRange[]; } -const ExplorerWrapper: FC<{ globalState: any; appState: any }> = ({ globalState, appState }) => { - const subscriptions = new Subscription(); - - const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState); - appState = getExplorerDefaultAppState(); - const { mlExplorerFilter, mlExplorerSwimlane } = appState; - window.setTimeout(() => { - // Pass the current URL AppState on to anomaly explorer's reactive state. - // After this hand-off, the appState stored in explorerState$ is the single - // source of truth. - explorerService.setAppState({ mlExplorerSwimlane, mlExplorerFilter }); - - // Now that appState in explorerState$ is the single source of truth, - // subscribe to it and update the actual URL appState on changes. - subscriptions.add( - explorerService.appState$.subscribe((appStateIn: ExplorerAppState) => { - // appState.fetch(); - appState.mlExplorerFilter = appStateIn.mlExplorerFilter; - appState.mlExplorerSwimlane = appStateIn.mlExplorerSwimlane; - // appState.save(); - }) - ); - }); +const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { + const [appState, setAppState] = useUrlState('_a'); + const [globalState] = useUrlState('_g'); + const [lastRefresh, setLastRefresh] = useState(0); - subscriptions.add(subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => {})); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => {}) - ); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => {}) - ); + const { jobIds } = useJobSelection(jobsWithTimeRange, getDateFormatTz()); - if (globalState.time) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); - } + const refresh = useRefresh(); + useEffect(() => { + if (refresh !== undefined) { + setLastRefresh(refresh?.lastRefresh); + const activeBounds = timefilter.getActiveBounds(); + if (activeBounds !== undefined) { + explorerService.setBounds(activeBounds); + } + } + }, [refresh?.lastRefresh]); useEffect(() => { - return () => { - subscriptions.unsubscribe(); - unsubscribeFromGlobalState(); - }; - }); + timefilter.enableTimeRangeSelector(); + timefilter.enableAutoRefreshSelector(); + + const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; + if (viewByFieldName !== undefined) { + explorerService.setViewBySwimlaneFieldName(viewByFieldName); + } + + const filterData = appState?.mlExplorerFilter; + if (filterData !== undefined) { + explorerService.setFilterData(filterData); + } + }, []); + + useEffect(() => { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + explorerService.setBounds({ + min: moment(globalState.time.from), + max: moment(globalState.time.to), + }); + } + }, [globalState?.time?.from, globalState?.time?.to]); + + useEffect(() => { + if (jobIds.length > 0) { + explorerService.updateJobSelection(jobIds); + } else { + explorerService.clearJobs(); + } + }, [JSON.stringify(jobIds)]); + + const [explorerData, loadExplorerData] = useExplorerData(); + useEffect(() => { + if (explorerData !== undefined && Object.keys(explorerData).length > 0) { + explorerService.setExplorerData(explorerData); + } + }, [explorerData]); + + const explorerAppState = useObservable(explorerService.appState$); + useEffect(() => { + if ( + explorerAppState !== undefined && + explorerAppState.mlExplorerSwimlane.viewByFieldName !== undefined + ) { + setAppState(explorerAppState); + } + }, [explorerAppState]); + + const explorerState = useObservable(explorerService.state$); + + const [showCharts] = useShowCharts(); + const [tableInterval] = useTableInterval(); + const [tableSeverity] = useTableSeverity(); + const [swimlaneLimit] = useSwimlaneLimit(); + useEffect(() => { + explorerService.setSwimlaneLimit(swimlaneLimit); + }, [swimlaneLimit]); + + const [selectedCells, setSelectedCells] = useSelectedCells(); + useEffect(() => { + explorerService.setSelectedCells(selectedCells); + }, [JSON.stringify(selectedCells)]); + + const loadExplorerDataConfig = + (explorerState !== undefined && { + bounds: explorerState.bounds, + lastRefresh, + influencersFilterQuery: explorerState.influencersFilterQuery, + noInfluencersConfigured: explorerState.noInfluencersConfigured, + selectedCells, + selectedJobs: explorerState.selectedJobs, + swimlaneBucketInterval: explorerState.swimlaneBucketInterval, + swimlaneLimit: explorerState.swimlaneLimit, + tableInterval: tableInterval.val, + tableSeverity: tableSeverity.val, + viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, + }) || + undefined; + useEffect(() => { + loadExplorerData(loadExplorerDataConfig); + }, [JSON.stringify(loadExplorerDataConfig)]); + + if (explorerState === undefined || refresh === undefined || showCharts === undefined) { + return null; + } return (
diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index a40bbfa214b281..cbf54a70ea74f4 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -4,24 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { decode } from 'rison-node'; +import { isEqual } from 'lodash'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { usePrevious } from 'react-use'; import moment from 'moment'; -import { Subscription } from 'rxjs'; - // @ts-ignore import queryString from 'query-string'; + +import { i18n } from '@kbn/i18n'; + import { timefilter } from 'ui/timefilter'; -import { MlRoute, PageLoader, PageProps } from '../router'; -import { useResolver } from '../use_resolver'; -import { basicResolvers } from '../resolvers'; + +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + import { TimeSeriesExplorer } from '../../timeseriesexplorer'; +import { getDateFormatTz, TimeRangeBounds } from '../../explorer/explorer_utils'; +import { ml } from '../../services/ml_api_service'; import { mlJobService } from '../../services/job_service'; +import { mlForecastService } from '../../services/forecast_service'; import { APP_STATE_ACTION } from '../../timeseriesexplorer/timeseriesexplorer_constants'; -import { subscribeAppStateToObservable } from '../../util/app_state_utils'; -import { interval$ } from '../../components/controls/select_interval'; -import { severity$ } from '../../components/controls/select_severity'; +import { + createTimeSeriesJobData, + getAutoZoomDuration, +} from '../../timeseriesexplorer/timeseriesexplorer_utils'; +import { useUrlState } from '../../util/url_state'; +import { useTableInterval } from '../../components/controls/select_interval'; +import { useTableSeverity } from '../../components/controls/select_severity'; + +import { MlRoute, PageLoader, PageProps } from '../router'; +import { useRefresh } from '../use_refresh'; +import { useResolver } from '../use_resolver'; +import { basicResolvers } from '../resolvers'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; export const timeSeriesExplorerRoute: MlRoute = { @@ -39,105 +52,207 @@ export const timeSeriesExplorerRoute: MlRoute = { ], }; -const PageWrapper: FC = ({ location, config, deps }) => { - const { context } = useResolver('', undefined, config, { +const PageWrapper: FC = ({ config, deps }) => { + const { context, results } = useResolver('', undefined, config, { ...basicResolvers(deps), jobs: mlJobService.loadJobsWrapper, + jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()), }); - const { _a, _g } = queryString.parse(location.search); - let appState: any = {}; - let globalState: any = {}; - try { - appState = decode(_a); - globalState = decode(_g); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Could not parse global or app state'); - } - if (appState.mlTimeSeriesExplorer === undefined) { - appState.mlTimeSeriesExplorer = {}; - } - globalState.fetch = () => {}; - globalState.on = () => {}; - globalState.off = () => {}; - globalState.save = () => {}; return ( - + ); }; -class AppState { - fetch() {} - on() {} - off() {} - save() {} +interface TimeSeriesExplorerUrlStateManager { + config: any; + jobsWithTimeRange: MlJobWithTimeRange[]; } -const TimeSeriesExplorerWrapper: FC<{ globalState: any; appState: any; config: any }> = ({ - globalState, - appState, +const TimeSeriesExplorerUrlStateManager: FC = ({ config, + jobsWithTimeRange, }) => { - if (globalState.time) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); - } + const [appState, setAppState] = useUrlState('_a'); + const [globalState, setGlobalState] = useUrlState('_g'); + const [lastRefresh, setLastRefresh] = useState(0); - const subscriptions = new Subscription(); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => {}) - ); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => {}) - ); + const refresh = useRefresh(); + useEffect(() => { + if (refresh !== undefined) { + setLastRefresh(refresh?.lastRefresh); - const appStateHandler = (action: string, payload: any) => { - switch (action) { - case APP_STATE_ACTION.CLEAR: - delete appState.mlTimeSeriesExplorer.detectorIndex; - delete appState.mlTimeSeriesExplorer.entities; - delete appState.mlTimeSeriesExplorer.forecastId; - break; - - case APP_STATE_ACTION.GET_DETECTOR_INDEX: - return appState.mlTimeSeriesExplorer.detectorIndex; - case APP_STATE_ACTION.SET_DETECTOR_INDEX: - appState.mlTimeSeriesExplorer.detectorIndex = payload; - break; - - case APP_STATE_ACTION.GET_ENTITIES: - return appState.mlTimeSeriesExplorer.entities; - case APP_STATE_ACTION.SET_ENTITIES: - appState.mlTimeSeriesExplorer.entities = payload; - break; - - case APP_STATE_ACTION.GET_FORECAST_ID: - return appState.mlTimeSeriesExplorer.forecastId; - case APP_STATE_ACTION.SET_FORECAST_ID: - appState.mlTimeSeriesExplorer.forecastId = payload; - break; - - case APP_STATE_ACTION.GET_ZOOM: - return appState.mlTimeSeriesExplorer.zoom; - case APP_STATE_ACTION.SET_ZOOM: - appState.mlTimeSeriesExplorer.zoom = payload; - break; - case APP_STATE_ACTION.UNSET_ZOOM: - delete appState.mlTimeSeriesExplorer.zoom; - break; + if (refresh.timeRange !== undefined) { + const { start, end } = refresh.timeRange; + setGlobalState('time', { + from: start, + to: end, + }); + } } - }; + }, [refresh?.lastRefresh]); useEffect(() => { - return () => { - subscriptions.unsubscribe(); + timefilter.enableTimeRangeSelector(); + timefilter.enableAutoRefreshSelector(); + }, []); + + useEffect(() => { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } + }, [globalState?.time?.from, globalState?.time?.to]); + + let bounds: TimeRangeBounds | undefined; + if (globalState?.time !== undefined) { + bounds = { + min: moment(globalState.time.from), + max: moment(globalState.time.to), }; - }); + } + + const selectedJobIds = globalState?.ml?.jobIds; + // Sort selectedJobIds so we can be sure comparison works when stringifying. + if (Array.isArray(selectedJobIds)) { + selectedJobIds.sort(); + } + + // When changing jobs we'll clear appState (detectorIndex, entities, forecastId). + // To retore settings from the URL on initial load we also need to check against + // `previousSelectedJobIds` to avoid wiping appState. + const previousSelectedJobIds = usePrevious(selectedJobIds); + const isJobChange = !isEqual(previousSelectedJobIds, selectedJobIds); + + // Use a side effect to clear appState when changing jobs. + useEffect(() => { + if (selectedJobIds !== undefined && previousSelectedJobIds !== undefined) { + setLastRefresh(Date.now()); + appStateHandler(APP_STATE_ACTION.CLEAR); + } + }, [JSON.stringify(selectedJobIds)]); + + // Next we get globalState and appState information to pass it on as props later. + // If a job change is going on, we fall back to defaults (as if appState was already cleard), + // otherwise the page could break. + const selectedDetectorIndex = isJobChange + ? 0 + : +appState?.mlTimeSeriesExplorer?.detectorIndex || 0; + const selectedEntities = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.entities; + const selectedForecastId = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.forecastId; + const zoom = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.zoom; + + const selectedJob = selectedJobIds && mlJobService.getJob(selectedJobIds[0]); + + let autoZoomDuration: number | undefined; + if (selectedJobIds !== undefined && selectedJobIds.length === 1 && selectedJob !== undefined) { + autoZoomDuration = getAutoZoomDuration( + createTimeSeriesJobData(mlJobService.jobs), + mlJobService.getJob(selectedJobIds[0]) + ); + } + + const appStateHandler = useCallback( + (action: string, payload?: any) => { + const mlTimeSeriesExplorer = + appState?.mlTimeSeriesExplorer !== undefined ? { ...appState.mlTimeSeriesExplorer } : {}; + + switch (action) { + case APP_STATE_ACTION.CLEAR: + delete mlTimeSeriesExplorer.detectorIndex; + delete mlTimeSeriesExplorer.entities; + delete mlTimeSeriesExplorer.forecastId; + delete mlTimeSeriesExplorer.zoom; + break; + + case APP_STATE_ACTION.SET_DETECTOR_INDEX: + mlTimeSeriesExplorer.detectorIndex = payload; + break; + + case APP_STATE_ACTION.SET_ENTITIES: + mlTimeSeriesExplorer.entities = payload; + break; + + case APP_STATE_ACTION.SET_FORECAST_ID: + mlTimeSeriesExplorer.forecastId = payload; + break; + + case APP_STATE_ACTION.SET_ZOOM: + mlTimeSeriesExplorer.zoom = payload; + break; + + case APP_STATE_ACTION.UNSET_ZOOM: + delete mlTimeSeriesExplorer.zoom; + break; + } + + setAppState('mlTimeSeriesExplorer', mlTimeSeriesExplorer); + }, + [JSON.stringify([appState, globalState])] + ); + + const boundsMinMs = bounds?.min?.valueOf(); + const boundsMaxMs = bounds?.max?.valueOf(); + useEffect(() => { + if ( + autoZoomDuration !== undefined && + boundsMinMs !== undefined && + boundsMaxMs !== undefined && + selectedJob !== undefined && + selectedForecastId !== undefined + ) { + mlForecastService + .getForecastDateRange(selectedJob, selectedForecastId) + .then(resp => { + if (autoZoomDuration === undefined) { + return; + } + + const earliest = moment(resp.earliest || boundsMinMs); + const latest = moment(resp.latest || boundsMaxMs); + + // Set the zoom to centre on the start of the forecast range, depending + // on the time range of the forecast and data. + // const earliestDataDate = first(contextChartData).date; + const zoomLatestMs = Math.min( + earliest.valueOf() + autoZoomDuration / 2, + latest.valueOf() + ); + const zoomEarliestMs = zoomLatestMs - autoZoomDuration; + const zoomState = { + from: moment(zoomEarliestMs).toISOString(), + to: moment(zoomLatestMs).toISOString(), + }; + appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + + if (earliest.isBefore(moment(boundsMinMs)) || latest.isAfter(moment(boundsMaxMs))) { + const earliestMs = Math.min(earliest.valueOf(), boundsMinMs); + const latestMs = Math.max(latest.valueOf(), boundsMaxMs); + setGlobalState('time', { + from: moment(earliestMs).toISOString(), + to: moment(latestMs).toISOString(), + }); + } + }) + .catch(resp => { + // eslint-disable-next-line no-console + console.error( + 'Time series explorer - error loading time range of forecast from elasticsearch:', + resp + ); + }); + } + }, [selectedForecastId]); + + const [tableInterval] = useTableInterval(); + const [tableSeverity] = useTableSeverity(); const tzConfig = config.get('dateFormat:tz'); const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); @@ -146,9 +261,20 @@ const TimeSeriesExplorerWrapper: FC<{ globalState: any; appState: any; config: a ); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts b/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts new file mode 100644 index 00000000000000..f9f3bb66f14f31 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useObservable } from 'react-use'; +import { merge, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { annotationsRefresh$ } from '../services/annotations_service'; +import { + mlTimefilterRefresh$, + mlTimefilterTimeChange$, +} from '../services/timefilter_refresh_service'; + +export interface Refresh { + lastRefresh: number; + timeRange?: { start: string; end: string }; +} + +const refresh$: Observable = merge( + mlTimefilterRefresh$, + mlTimefilterTimeChange$, + annotationsRefresh$.pipe(map(d => ({ lastRefresh: d }))) +); + +export const useRefresh = () => { + return useObservable(refresh$); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx index d74c3802c2ed2e..2ba54d243ed1b3 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx +++ b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx @@ -7,7 +7,7 @@ import mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json'; import { Annotation } from '../../../common/types/annotations'; -import { annotation$, annotationsRefresh$ } from './annotations_service'; +import { annotation$, annotationsRefresh$, annotationsRefreshed } from './annotations_service'; describe('annotations_service', () => { test('annotation$', () => { @@ -34,7 +34,7 @@ describe('annotations_service', () => { expect(subscriber.mock.calls).toHaveLength(1); - annotationsRefresh$.next(true); + annotationsRefreshed(); expect(subscriber.mock.calls).toHaveLength(2); }); diff --git a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx index 6953232f0cc6c2..6493770156cb81 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx +++ b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx @@ -48,8 +48,8 @@ export type AnnotationState = Annotation | null; - To add it to a given components state, just use `annotation$.subscribe(annotation => this.setState({ annotation }));` in `componentDidMount()`. - 2. injectObservablesAsProps() from public/utils/observable_utils.tsx, as the name implies, offers - a way to wrap observables into another component which passes on updated values as props. + 2. useObservable() from 'react-use', offers a way to wrap observables + into another component which passes on updated values as props. - To subscribe to updates this way, wrap your component like: @@ -62,10 +62,13 @@ export type AnnotationState = Annotation | null; return {annotation.annotation}; } - export const MyObservableComponent = injectObservablesAsProps( - { annotation: annotaton$ }, - MyOriginalComponent - ); + export const MyObservableComponent = (props) => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; + }; */ export const annotation$ = new BehaviorSubject(null); @@ -74,4 +77,5 @@ export const annotation$ = new BehaviorSubject(null); Instead of passing around callbacks or deeply nested props, it can be imported for both angularjs controllers/directives and React components. */ -export const annotationsRefresh$ = new BehaviorSubject(false); +export const annotationsRefresh$ = new BehaviorSubject(Date.now()); +export const annotationsRefreshed = () => annotationsRefresh$.next(Date.now()); diff --git a/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts b/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts index 19f77d97a57080..8de903a422f340 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts @@ -12,6 +12,11 @@ export interface ForecastData { results: any; } +export interface ForecastDateRange { + earliest: number; + latest: number; +} + export const mlForecastService: { getForecastData: ( job: Job, @@ -23,4 +28,6 @@ export const mlForecastService: { interval: string, aggType: any ) => Observable; + + getForecastDateRange: (job: Job, forecastId: string) => Promise; }; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts index 2ad2a148f05d1a..bca32e9528f64a 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts @@ -6,11 +6,12 @@ import { Observable } from 'rxjs'; import { Annotation } from '../../../../common/types/annotations'; +import { Dictionary } from '../../../../common/types/common'; import { AggFieldNamePair } from '../../../../common/types/fields'; import { Category } from '../../../../common/types/categories'; import { ExistingJobsAndGroups } from '../job_service'; import { PrivilegesResponse } from '../../../../common/types/privileges'; -import { MlSummaryJobs } from '../../../../common/types/jobs'; +import { MlJobWithTimeRange, MlSummaryJobs } from '../../../../common/types/jobs'; import { MlServerDefaults, MlServerLimits } from '../ml_server_info'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; @@ -135,6 +136,9 @@ declare interface Ml { jobs: { jobsSummary(jobIds: string[]): Promise; + jobsWithTimerange( + dateFormatTz: string + ): Promise<{ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary }>; jobs(jobIds: string[]): Promise; groups(): Promise; updateGroups(updatedJobs: string[]): Promise; diff --git a/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx b/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx index 2085c2a5dc77f2..86c07a3577f7b6 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx +++ b/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx @@ -6,4 +6,7 @@ import { Subject } from 'rxjs'; -export const mlTimefilterRefresh$ = new Subject(); +import { Refresh } from '../routing/use_refresh'; + +export const mlTimefilterRefresh$ = new Subject>(); +export const mlTimefilterTimeChange$ = new Subject>(); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js deleted file mode 100644 index 32b4fa3df3cf01..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; - -describe('ML - Time Series Explorer Directive', () => { - let $scope; - let $compile; - let $element; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function($injector) { - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - $scope.$destroy(); - }); - - it('Initialize Time Series Explorer Directive', done => { - ngMock.inject(function() { - expect(() => { - $element = $compile('')($scope); - }).to.not.throwError(); - - // directive has scope: false - const scope = $element.isolateScope(); - expect(scope).to.eql(undefined); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx index bc6896a1a66ba0..df5412e609a9c9 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx @@ -22,21 +22,13 @@ export interface Entity { fieldValues: any; } -function getEntityControlOptions(entity: Entity): EuiComboBoxOptionProps[] { - if (!Array.isArray(entity.fieldValues)) { - return []; - } - - return entity.fieldValues.map(value => { - return { label: value }; - }); -} - interface EntityControlProps { entity: Entity; entityFieldValueChanged: (entity: Entity, fieldValue: any) => void; + isLoading: boolean; onSearchChange: (entity: Entity, queryTerm: string) => void; forceSelection: boolean; + options: EuiComboBoxOptionProps[]; } interface EntityControlState { @@ -55,17 +47,11 @@ export class EntityControl extends Component 0) || @@ -79,11 +65,13 @@ export class EntityControl extends Component { - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.closeModal(); }; @@ -279,7 +279,7 @@ export const ForecastingModal = injectI18n( this.setState({ jobClosingState: PROGRESS_STATES.DONE, }); - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.closeAfterRunningForecast(); }) .catch(response => { @@ -297,10 +297,10 @@ export const ForecastingModal = injectI18n( this.setState({ jobClosingState: PROGRESS_STATES.ERROR, }); - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); }); } else { - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.closeAfterRunningForecast(); } } else { @@ -327,7 +327,7 @@ export const ForecastingModal = injectI18n( ); // Try and load any results which may have been created. - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.setState({ forecastProgress: PROGRESS_STATES.ERROR }); clearInterval(this.forecastChecker); } diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 4d10d73bcc0489..d8e9e4379395a5 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -11,7 +11,7 @@ import PropTypes from 'prop-types'; import React from 'react'; - +import useObservable from 'react-use/lib/useObservable'; import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; @@ -23,7 +23,6 @@ import { getMultiBucketImpactLabel, } from '../../../../../common/util/anomaly_utils'; import { annotation$ } from '../../../services/annotations_service'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; import { formatValue } from '../../../formatters/format_value'; import { LINE_CHART_ANOMALY_RADIUS, @@ -97,16 +96,16 @@ const TimeseriesChartIntl = injectI18n( static propTypes = { annotation: PropTypes.object, autoZoomDuration: PropTypes.number, + bounds: PropTypes.object, contextAggregationInterval: PropTypes.object, contextChartData: PropTypes.array, contextForecastData: PropTypes.array, contextChartSelected: PropTypes.func.isRequired, - detectorIndex: PropTypes.string, + detectorIndex: PropTypes.number, focusAggregationInterval: PropTypes.object, focusAnnotationData: PropTypes.array, focusChartData: PropTypes.array, focusForecastData: PropTypes.array, - skipRefresh: PropTypes.bool.isRequired, modelPlotEnabled: PropTypes.bool.isRequired, renderFocusChartOnly: PropTypes.bool.isRequired, selectedJob: PropTypes.object, @@ -114,7 +113,6 @@ const TimeseriesChartIntl = injectI18n( showModelBounds: PropTypes.bool.isRequired, svgWidth: PropTypes.number.isRequired, swimlaneData: PropTypes.array, - timefilter: PropTypes.object.isRequired, zoomFrom: PropTypes.object, zoomTo: PropTypes.object, zoomFromFocusLoaded: PropTypes.object, @@ -234,10 +232,6 @@ const TimeseriesChartIntl = injectI18n( } componentDidUpdate() { - if (this.props.skipRefresh) { - return; - } - if (this.props.renderFocusChartOnly === false) { this.renderChart(); this.drawContextChartSelection(); @@ -887,13 +881,12 @@ const TimeseriesChartIntl = injectI18n( } createZoomInfoElements(zoomGroup, fcsWidth) { - const { autoZoomDuration, modelPlotEnabled, timefilter, intl } = this.props; + const { autoZoomDuration, bounds, modelPlotEnabled, intl } = this.props; const setZoomInterval = this.setZoomInterval.bind(this); // Create zoom duration links applicable for the current time span. // Don't add links for any durations which would give a brush extent less than 10px. - const bounds = timefilter.getActiveBounds(); const boundsSecs = bounds.max.unix() - bounds.min.unix(); const minSecs = (10 / this.vizWidth) * boundsSecs; @@ -968,7 +961,7 @@ const TimeseriesChartIntl = injectI18n( } drawContextElements(cxtGroup, cxtWidth, cxtChartHeight, swlHeight) { - const { contextChartData, contextForecastData, modelPlotEnabled, timefilter } = this.props; + const { bounds, contextChartData, contextForecastData, modelPlotEnabled } = this.props; const data = contextChartData; @@ -1034,7 +1027,6 @@ const TimeseriesChartIntl = injectI18n( .attr('y2', cxtChartHeight + swlHeight); // Add x axis. - const bounds = timefilter.getActiveBounds(); const timeBuckets = new TimeBuckets(); timeBuckets.setInterval('auto'); timeBuckets.setBounds(bounds); @@ -1362,13 +1354,12 @@ const TimeseriesChartIntl = injectI18n( }; calculateContextXAxisDomain = () => { - const { contextAggregationInterval, swimlaneData, timefilter } = this.props; + const { bounds, contextAggregationInterval, swimlaneData } = this.props; // Calculates the x axis domain for the context elements. // Elasticsearch aggregation returns points at start of bucket, // so set the x-axis min to the start of the first aggregation interval, // and the x-axis max to the end of the last aggregation interval. // Context chart and swimlane use the same aggregation interval. - const bounds = timefilter.getActiveBounds(); let earliest = bounds.min.valueOf(); if (swimlaneData !== undefined && swimlaneData.length > 0) { @@ -1406,9 +1397,8 @@ const TimeseriesChartIntl = injectI18n( }; setZoomInterval(ms) { - const { timefilter, zoomTo } = this.props; + const { bounds, zoomTo } = this.props; - const bounds = timefilter.getActiveBounds(); const minBoundsMs = bounds.min.valueOf(); const maxBoundsMs = bounds.max.valueOf(); @@ -1726,7 +1716,10 @@ const TimeseriesChartIntl = injectI18n( } ); -export const TimeseriesChart = injectObservablesAsProps( - { annotation: annotation$ }, - TimeseriesChartIntl -); +export const TimeseriesChart = props => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js index fb52d191013f75..cc77ad9f1a9859 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js @@ -46,7 +46,6 @@ function getTimeseriesChartPropsMock() { showModelBounds: true, svgWidth: 1600, timefilter: {}, - skipRefresh: false, }; } diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index ac4bc6186e5b47..3edbbc1af23237 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -10,6 +10,12 @@ import { FC } from 'react'; declare const TimeSeriesExplorer: FC<{ appStateHandler: (action: string, payload: any) => void; dateFormatTz: string; - globalState: any; + selectedJobIds: string[]; + selectedDetectorIndex: number; + selectedEntities: any[]; + selectedForecastId: string; + setGlobalState: (arg: any) => void; + tableInterval: string; + tableSeverity: number; timefilter: Timefilter; }>; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 0ab10c4fe69cd9..807a368fc9b349 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -8,7 +8,7 @@ * React component for rendering Single Metric Viewer. */ -import { debounce, difference, each, find, first, get, has, isEqual, without } from 'lodash'; +import { debounce, difference, each, find, get, has, isEqual, without } from 'lodash'; import moment from 'moment-timezone'; import { Subject, Subscription, forkJoin } from 'rxjs'; import { map, debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators'; @@ -36,42 +36,34 @@ import { toastNotifications } from 'ui/notify'; import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/public'; import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; -import { parseInterval } from '../../../common/util/parse_interval'; import { isModelPlotEnabled, isSourceDataChartableForDetector, - isTimeSeriesViewJob, isTimeSeriesViewDetector, mlFunctionToESAggregation, } from '../../../common/util/job_utils'; -import { ChartTooltip } from '../components/chart_tooltip'; -import { - jobSelectServiceFactory, - setGlobalState, - getSelectedJobIds, -} from '../components/job_selector/job_select_service_utils'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; +import { ChartTooltip } from '../components/chart_tooltip'; import { EntityControl } from './components/entity_control'; import { ForecastingModal } from './components/forecasting_modal/forecasting_modal'; import { JobSelector } from '../components/job_selector'; +import { getTimeRangeFromSelection } from '../components/job_selector/job_select_service_utils'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; -import { severity$, SelectSeverity } from '../components/controls/select_severity/select_severity'; -import { interval$, SelectInterval } from '../components/controls/select_interval/select_interval'; +import { SelectInterval } from '../components/controls/select_interval/select_interval'; +import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart'; import { TimeseriesexplorerNoJobsFound } from './components/timeseriesexplorer_no_jobs_found'; import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data'; -import { annotationsRefresh$ } from '../services/annotations_service'; import { ml } from '../services/ml_api_service'; import { mlFieldFormatService } from '../services/field_format_service'; import { mlForecastService } from '../services/forecast_service'; import { mlJobService } from '../services/job_service'; import { mlResultsService } from '../services/results_service'; -import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; import { getBoundsRoundedToInterval } from '../util/time_buckets'; @@ -86,7 +78,6 @@ import { calculateDefaultFocusRange, calculateInitialFocusRange, createTimeSeriesJobData, - getAutoZoomDuration, processForecastResults, processMetricPlotResults, processRecordScoreResults, @@ -102,33 +93,57 @@ const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionV defaultMessage: 'all', }); +function getEntityControlOptions(fieldValues) { + if (!Array.isArray(fieldValues)) { + return []; + } + + return fieldValues.map(value => { + return { label: value }; + }); +} + +function getViewableDetectors(selectedJob) { + const jobDetectors = selectedJob.analysis_config.detectors; + const viewableDetectors = []; + each(jobDetectors, (dtr, index) => { + if (isTimeSeriesViewDetector(selectedJob, index)) { + viewableDetectors.push({ + index, + detector_description: dtr.detector_description, + }); + } + }); + return viewableDetectors; +} + function getTimeseriesexplorerDefaultState() { return { chartDetails: undefined, + contextAggregationInterval: undefined, contextChartData: undefined, contextForecastData: undefined, // Not chartable if e.g. model plot with terms for a varp detector dataNotChartable: false, - detectorId: undefined, - detectors: [], - entities: [], + entitiesLoading: false, + entityValues: {}, focusAnnotationData: [], focusChartData: undefined, focusForecastData: undefined, fullRefresh: true, hasResults: false, - jobs: [], // Counter to keep track of what data sets have been loaded. loadCounter: 0, loading: false, modelPlotEnabled: false, - selectedJob: undefined, // Toggles display of annotations in the focus chart showAnnotations: mlAnnotationsEnabled, showAnnotationsCheckbox: mlAnnotationsEnabled, // Toggles display of forecast data in the focus chart showForecast: true, showForecastCheckbox: false, + // Toggles display of model bounds in the focus chart + showModelBounds: true, showModelBoundsCheckbox: false, svgWidth: 0, tableData: undefined, @@ -136,9 +151,6 @@ function getTimeseriesexplorerDefaultState() { zoomTo: undefined, zoomFromFocusLoaded: undefined, zoomToFocusLoaded: undefined, - - // Toggles display of model bounds in the focus chart - showModelBounds: true, }; } @@ -174,26 +186,23 @@ const containerPadding = 24; export class TimeSeriesExplorer extends React.Component { static propTypes = { appStateHandler: PropTypes.func.isRequired, + autoZoomDuration: PropTypes.number, + bounds: PropTypes.object, dateFormatTz: PropTypes.string.isRequired, - globalState: PropTypes.object.isRequired, - timefilter: PropTypes.object.isRequired, + jobsWithTimeRange: PropTypes.array.isRequired, + lastRefresh: PropTypes.number.isRequired, + selectedJobIds: PropTypes.arrayOf(PropTypes.string), + selectedDetectorIndex: PropTypes.number, + selectedEntities: PropTypes.object, + selectedForecastId: PropTypes.string, + tableInterval: PropTypes.string, + tableSeverity: PropTypes.number, }; state = getTimeseriesexplorerDefaultState(); subscriptions = new Subscription(); - _criteriaFields = null; - - constructor(props) { - super(props); - const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory( - props.globalState - ); - this.jobSelectService$ = jobSelectService$; - this.unsubscribeFromGlobalState = unsubscribeFromGlobalState; - } - resizeRef = createRef(); resizeChecker = undefined; resizeHandler = () => { @@ -209,13 +218,10 @@ export class TimeSeriesExplorer extends React.Component { contextChart$ = new Subject(); detectorIndexChangeHandler = e => { + const { appStateHandler } = this.props; const id = e.target.value; if (id !== undefined) { - this.setState({ detectorId: id }, () => { - this.updateControlsForDetector(() => - this.loadEntityValues(() => this.saveSeriesPropertiesAndRefresh()) - ); - }); + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +id); } }; @@ -245,7 +251,7 @@ export class TimeSeriesExplorer extends React.Component { previousShowModelBounds = undefined; tableFilter = (field, value, operator) => { - const { entities } = this.state; + const entities = this.getControlsForDetector(); const entity = entities.find(({ fieldName }) => fieldName === field); if (entity === undefined) { @@ -272,35 +278,14 @@ export class TimeSeriesExplorer extends React.Component { }; appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); - - this.updateControlsForDetector(() => { - this.refresh(); - }); }; contextChartSelectedInitCallDone = false; - /** - * Gets default range from component state. - */ - getDefaultRangeFromState() { - const { - autoZoomDuration, - contextAggregationInterval, - contextChartData, - contextForecastData, - } = this.state; - - return calculateDefaultFocusRange( - autoZoomDuration, - contextAggregationInterval, - contextChartData, - contextForecastData - ); - } - getFocusAggregationInterval(selection) { - const { jobs, selectedJob } = this.state; + const { selectedJobIds } = this.props; + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const selectedJob = mlJobService.getJob(selectedJobIds[0]); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; @@ -312,13 +297,13 @@ export class TimeSeriesExplorer extends React.Component { * Gets focus data for the current component state/ */ getFocusData(selection) { - const { detectorId, entities, modelPlotEnabled, selectedJob } = this.state; - - const { appStateHandler } = this.props; + const { selectedJobIds, selectedForecastId, selectedDetectorIndex } = this.props; + const { modelPlotEnabled } = this.state; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const entityControls = this.getControlsForDetector(); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; - const focusAggregationInterval = this.getFocusAggregationInterval(selection); // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. @@ -327,12 +312,12 @@ export class TimeSeriesExplorer extends React.Component { const searchBounds = getBoundsRoundedToInterval(bounds, focusAggregationInterval, false); return getFocusData( - this._criteriaFields, - +detectorId, + this.getCriteriaFields(selectedDetectorIndex, entityControls), + selectedDetectorIndex, focusAggregationInterval, - appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID), + selectedForecastId, modelPlotEnabled, - entities.filter(entity => entity.fieldValue.length > 0), + entityControls.filter(entity => entity.fieldValue.length > 0), searchBounds, selectedJob, TIME_FIELD_NAME @@ -345,10 +330,10 @@ export class TimeSeriesExplorer extends React.Component { entityFieldValueChanged = (entity, fieldValue) => { const { appStateHandler } = this.props; - const { entities } = this.state; + const entityControls = this.getControlsForDetector(); const resultEntities = { - ...entities.reduce((appStateEntities, appStateEntity) => { + ...entityControls.reduce((appStateEntities, appStateEntity) => { appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue; return appStateEntities; }, {}), @@ -356,29 +341,33 @@ export class TimeSeriesExplorer extends React.Component { }; appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); - - this.updateControlsForDetector(() => { - this.refresh(); - }); }; entityFieldSearchChanged = debounce((entity, queryTerm) => { - this.loadEntityValues({ + const entityControls = this.getControlsForDetector(); + this.loadEntityValues(entityControls, { [entity.fieldType]: queryTerm, }); }, 500); loadAnomaliesTableData = (earliestMs, latestMs) => { - const { dateFormatTz } = this.props; - const { selectedJob } = this.state; + const { + dateFormatTz, + selectedDetectorIndex, + selectedJobIds, + tableInterval, + tableSeverity, + } = this.props; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const entityControls = this.getControlsForDetector(); return ml.results .getAnomaliesTableData( [selectedJob.job_id], - this._criteriaFields, + this.getCriteriaFields(selectedDetectorIndex, entityControls), [], - interval$.getValue().val, - severity$.getValue().val, + tableInterval, + tableSeverity, earliestMs, latestMs, dateFormatTz, @@ -427,16 +416,18 @@ export class TimeSeriesExplorer extends React.Component { /** * Loads available entity values. + * @param {Array} entities - Entity controls configuration * @param {Object} searchTerm - Search term for partition, e.g. { partition_field: 'partition' } - * @param callback - Callback to execute after component state update. */ - loadEntityValues = async (searchTerm = {}, callback = () => {}) => { - const { timefilter } = this.props; - const { detectorId, entities, selectedJob } = this.state; + loadEntityValues = async (entities, searchTerm = {}) => { + this.setState({ entitiesLoading: true }); + + const { bounds, selectedJobIds, selectedDetectorIndex } = this.props; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); - // Populate the entity input datalists with aggregated values. No need to pass through finish(). - const bounds = timefilter.getActiveBounds(); - const detectorIndex = +detectorId; + // Populate the entity input datalists with the values from the top records by score + // for the selected detector across the full time range. No need to pass through finish(). + const detectorIndex = selectedDetectorIndex; const { partition_field: partitionField, @@ -457,98 +448,46 @@ export class TimeSeriesExplorer extends React.Component { ) .toPromise(); - this.setState( - { - entities: entities.map(entity => { - const newEntity = { ...entity }; - if (partitionField?.name === entity.fieldName) { - newEntity.fieldValues = partitionField.values; - } - if (overField?.name === entity.fieldName) { - newEntity.fieldValues = overField.values; - } - if (byField?.name === entity.fieldName) { - newEntity.fieldValues = byField.values; - } - return newEntity; - }), - }, - callback - ); - }; - - loadForForecastId = forecastId => { - const { appStateHandler, timefilter } = this.props; - const { autoZoomDuration, contextChartData, selectedJob } = this.state; - - mlForecastService - .getForecastDateRange(selectedJob, forecastId) - .then(resp => { - const bounds = timefilter.getActiveBounds(); - const earliest = moment(resp.earliest || timefilter.getTime().from); - const latest = moment(resp.latest || timefilter.getTime().to); - - // Store forecast ID in the appState. - appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); - - // Set the zoom to centre on the start of the forecast range, depending - // on the time range of the forecast and data. - const earliestDataDate = first(contextChartData).date; - const zoomLatestMs = Math.min(earliest + autoZoomDuration / 2, latest.valueOf()); - const zoomEarliestMs = Math.max( - zoomLatestMs - autoZoomDuration, - earliestDataDate.getTime() - ); + const entityValues = {}; + entities.forEach(entity => { + let fieldValues; - const zoomState = { - from: moment(zoomEarliestMs).toISOString(), - to: moment(zoomLatestMs).toISOString(), - }; - appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); - - // Ensure the forecast data will be shown if hidden previously. - this.setState({ showForecast: true }); + if (partitionField?.name === entity.fieldName) { + fieldValues = partitionField.values; + } + if (overField?.name === entity.fieldName) { + fieldValues = overField.values; + } + if (byField?.name === entity.fieldName) { + fieldValues = byField.values; + } + entityValues[entity.fieldName] = fieldValues; + }); - if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) { - const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf()); - const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf()); + this.setState({ entitiesLoading: false, entityValues }); + }; - timefilter.setTime({ - from: moment(earliestMs).toISOString(), - to: moment(latestMs).toISOString(), - }); - } else { - // Refresh to show the requested forecast data. - this.refresh(); - } - }) - .catch(resp => { - console.log( - 'Time series explorer - error loading time range of forecast from elasticsearch:', - resp - ); - }); + setForecastId = forecastId => { + this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); }; - refresh = (fullRefresh = true) => { - // Skip the refresh if: - // a) The global state's `skipRefresh` was set to true by the job selector to avoid race conditions - // when loading the Single Metric Viewer after a job/group and time range update. - // b) A 'soft' refresh without a full page reload is already happening. - if ( - get(this.props.globalState, 'ml.skipRefresh') || - (this.state.loading && fullRefresh === false) - ) { + loadSingleMetricData = (fullRefresh = true) => { + const { + autoZoomDuration, + bounds, + selectedDetectorIndex, + selectedForecastId, + selectedJobIds, + zoom, + } = this.props; + + if (selectedJobIds === undefined) { return; } - const { appStateHandler, timefilter } = this.props; - const { - detectorId: currentDetectorId, - entities: currentEntities, - loadCounter: currentLoadCounter, - selectedJob: currentSelectedJob, - } = this.state; + const { loadCounter: currentLoadCounter } = this.state; + + const currentSelectedJob = mlJobService.getJob(selectedJobIds[0]); if (currentSelectedJob === undefined) { return; @@ -558,6 +497,7 @@ export class TimeSeriesExplorer extends React.Component { // Only when `fullRefresh` is true we'll reset all data // and show the loading spinner within the page. + const entityControls = this.getControlsForDetector(); this.setState( { fullRefresh, @@ -572,8 +512,8 @@ export class TimeSeriesExplorer extends React.Component { focusForecastData: undefined, modelPlotEnabled: isModelPlotEnabled( currentSelectedJob, - +currentDetectorId, - currentEntities + selectedDetectorIndex, + entityControls ), hasResults: false, dataNotChartable: false, @@ -581,15 +521,11 @@ export class TimeSeriesExplorer extends React.Component { : {}), }, () => { - const { - detectorId, - entities, - loadCounter, - jobs, - modelPlotEnabled, - selectedJob, - } = this.state; - const detectorIndex = +detectorId; + const { loadCounter, modelPlotEnabled } = this.state; + + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const detectorIndex = selectedDetectorIndex; let awaitingCount = 3; @@ -609,19 +545,16 @@ export class TimeSeriesExplorer extends React.Component { // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically // selecting the specified range in the context chart, and so loading that date range in the focus chart. if (stateUpdate.contextChartData.length) { - // Calculate the 'auto' zoom duration which shows data at bucket span granularity. - stateUpdate.autoZoomDuration = getAutoZoomDuration(jobs, selectedJob); - // Check for a zoom parameter in the appState (URL). let focusRange = calculateInitialFocusRange( - appStateHandler(APP_STATE_ACTION.GET_ZOOM), + zoom, stateUpdate.contextAggregationInterval, - timefilter + bounds ); if (focusRange === undefined) { focusRange = calculateDefaultFocusRange( - stateUpdate.autoZoomDuration, + autoZoomDuration, stateUpdate.contextAggregationInterval, stateUpdate.contextChartData, stateUpdate.contextForecastData @@ -636,7 +569,7 @@ export class TimeSeriesExplorer extends React.Component { } }; - const nonBlankEntities = currentEntities.filter(entity => { + const nonBlankEntities = entityControls.filter(entity => { return entity.fieldValue.length > 0; }); @@ -654,8 +587,6 @@ export class TimeSeriesExplorer extends React.Component { return; } - const bounds = timefilter.getActiveBounds(); - // Calculate the aggregation interval for the context chart. // Context chart swimlane will display bucket anomaly score at the same interval. stateUpdate.contextAggregationInterval = calculateAggregationInterval( @@ -706,7 +637,7 @@ export class TimeSeriesExplorer extends React.Component { mlResultsService .getRecordMaxScoreByTime( selectedJob.job_id, - this._criteriaFields, + this.getCriteriaFields(detectorIndex, entityControls), searchBounds.min.valueOf(), searchBounds.max.valueOf(), stateUpdate.contextAggregationInterval.expression @@ -728,7 +659,7 @@ export class TimeSeriesExplorer extends React.Component { .getChartDetails( selectedJob, detectorIndex, - entities, + entityControls, searchBounds.min.valueOf(), searchBounds.max.valueOf() ) @@ -744,8 +675,7 @@ export class TimeSeriesExplorer extends React.Component { }); // Plus query for forecast data if there is a forecastId stored in the appState. - const forecastId = appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID); - if (forecastId !== undefined) { + if (selectedForecastId !== undefined) { awaitingCount++; let aggType = undefined; const detector = selectedJob.analysis_config.detectors[detectorIndex]; @@ -757,7 +687,7 @@ export class TimeSeriesExplorer extends React.Component { .getForecastData( selectedJob, detectorIndex, - forecastId, + selectedForecastId, nonBlankEntities, searchBounds.min.valueOf(), searchBounds.max.valueOf(), @@ -771,13 +701,11 @@ export class TimeSeriesExplorer extends React.Component { }) .catch(resp => { console.log( - `Time series explorer - error loading data for forecast ID ${forecastId}`, + `Time series explorer - error loading data for forecast ID ${selectedForecastId}`, resp ); }); } - - this.loadEntityValues(); } ); }; @@ -786,15 +714,21 @@ export class TimeSeriesExplorer extends React.Component { * Updates local state of detector related controls from the global state. * @param callback to invoke after a state update. */ - updateControlsForDetector = (callback = () => {}) => { - const { appStateHandler } = this.props; - const { detectorId, selectedJob } = this.state; + getControlsForDetector = () => { + const { selectedDetectorIndex, selectedEntities, selectedJobIds } = this.props; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + + const entities = []; + + if (selectedJob === undefined) { + return entities; + } + // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. - const detectorIndex = +detectorId; + const detectorIndex = selectedDetectorIndex; const detector = selectedJob.analysis_config.detectors[detectorIndex]; - const entities = []; - const entitiesState = appStateHandler(APP_STATE_ACTION.GET_ENTITIES); + const entitiesState = selectedEntities; const partitionFieldName = get(detector, 'partition_field_name'); const overFieldName = get(detector, 'over_field_name'); const byFieldName = get(detector, 'by_field_name'); @@ -825,9 +759,7 @@ export class TimeSeriesExplorer extends React.Component { entities.push({ fieldType: 'by_field', fieldName: byFieldName, fieldValue: byFieldValue }); } - this.updateCriteriaFields(detectorIndex, entities); - - this.setState({ entities }, callback); + return entities; }; /** @@ -835,10 +767,10 @@ export class TimeSeriesExplorer extends React.Component { * @param detectorIndex * @param entities */ - updateCriteriaFields(detectorIndex, entities) { + getCriteriaFields(detectorIndex, entities) { // Only filter on the entity if the field has a value. const nonBlankEntities = entities.filter(entity => entity.fieldValue.length > 0); - this._criteriaFields = [ + return [ { fieldName: 'detector_index', fieldValue: detectorIndex, @@ -847,47 +779,21 @@ export class TimeSeriesExplorer extends React.Component { ]; } - loadForJobId(jobId, jobs) { - const { appStateHandler } = this.props; - - // Validation that the ID is for a time series job must already have been performed. - // Check if the job was created since the page was first loaded. - let jobPickerSelectedJob = find(jobs, { id: jobId }); - if (jobPickerSelectedJob === undefined) { - const newJobs = []; - each(mlJobService.jobs, job => { - if (isTimeSeriesViewJob(job) === true) { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - newJobs.push({ - id: job.job_id, - selected: false, - bucketSpanSeconds: bucketSpan.asSeconds(), - }); - } - }); - this.setState({ jobs: newJobs }); - jobPickerSelectedJob = find(newJobs, { id: jobId }); - } + loadForJobId(jobId) { + const { appStateHandler, selectedDetectorIndex } = this.props; const selectedJob = mlJobService.getJob(jobId); - // Read the detector index and entities out of the AppState. - const jobDetectors = selectedJob.analysis_config.detectors; - const viewableDetectors = []; - each(jobDetectors, (dtr, index) => { - if (isTimeSeriesViewDetector(selectedJob, index)) { - viewableDetectors.push({ - index: '' + index, - detector_description: dtr.detector_description, - }); - } - }); - const detectors = viewableDetectors; + if (selectedJob === undefined) { + return; + } + + const detectors = getViewableDetectors(selectedJob); // Check the supplied index is valid. - const appStateDtrIdx = appStateHandler(APP_STATE_ACTION.GET_DETECTOR_INDEX); - let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +viewableDetectors[0].index; - if (find(viewableDetectors, { index: '' + detectorIndex }) === undefined) { + const appStateDtrIdx = selectedDetectorIndex; + let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : detectors[0].index; + if (find(detectors, { index: detectorIndex }) === undefined) { const warningText = i18n.translate( 'xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', { @@ -899,179 +805,22 @@ export class TimeSeriesExplorer extends React.Component { } ); toastNotifications.addWarning(warningText); - detectorIndex = +viewableDetectors[0].index; - appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorIndex); + detectorIndex = detectors[0].index; } - // Store the detector index as a string so it can be used as ng-model in a select control. - const detectorId = '' + detectorIndex; + const detectorId = detectorIndex; - this.setState({ detectorId, detectors, selectedJob }, () => { - this.updateControlsForDetector(() => { - // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. - mlFieldFormatService - .populateFormats([jobId]) - .catch(err => { - console.log('Error populating field formats:', err); - }) - // Load the data - if the FieldFormats failed to populate - // the default formatting will be used for metric values. - .then(() => { - this.refresh(); - }); - }); + if (detectorId !== selectedDetectorIndex) { + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorId); + } + + // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. + mlFieldFormatService.populateFormats([jobId]).catch(err => { + console.log('Error populating field formats:', err); }); } - saveSeriesPropertiesAndRefresh = () => { - const { appStateHandler } = this.props; - const { detectorId, entities } = this.state; - - appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +detectorId); - appStateHandler( - APP_STATE_ACTION.SET_ENTITIES, - entities.reduce((appStateEntities, entity) => { - appStateEntities[entity.fieldName] = entity.fieldValue; - return appStateEntities; - }, {}) - ); - - this.refresh(); - }; - componentDidMount() { - const { appStateHandler, globalState, timefilter } = this.props; - - this.setState({ jobs: [] }); - - // Get the job info needed by the visualization, then do the first load. - if (mlJobService.jobs.length > 0) { - const jobs = createTimeSeriesJobData(mlJobService.jobs); - this.setState({ jobs }); - } else { - this.setState({ loading: false }); - } - - // Reload the anomalies table if the Interval or Threshold controls are changed. - const tableControlsListener = () => { - const { zoomFrom, zoomTo } = this.state; - if (zoomFrom !== undefined && zoomTo !== undefined) { - this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()).subscribe(res => - this.setState(res) - ); - } - }; - - this.subscriptions.add(annotationsRefresh$.subscribe(this.refresh)); - this.subscriptions.add(interval$.subscribe(tableControlsListener)); - this.subscriptions.add(severity$.subscribe(tableControlsListener)); - this.subscriptions.add( - mlTimefilterRefresh$.subscribe(() => { - this.refresh(true); - }) - ); - - // Listen for changes to job selection. - this.subscriptions.add( - this.jobSelectService$.subscribe(({ selection: selectedJobIds }) => { - const jobs = createTimeSeriesJobData(mlJobService.jobs); - - this.contextChartSelectedInitCallDone = false; - this.setState({ fullRefresh: false, loading: true, showForecastCheckbox: false }); - - const timeSeriesJobIds = jobs.map(j => j.id); - - // Check if any of the jobs set in the URL are not time series jobs - // (e.g. if switching to this view straight from the Anomaly Explorer). - const invalidIds = difference(selectedJobIds, timeSeriesJobIds); - selectedJobIds = without(selectedJobIds, ...invalidIds); - if (invalidIds.length > 0) { - let warningText = i18n.translate( - 'xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', - { - defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, - values: { - invalidIdsCount: invalidIds.length, - invalidIds, - }, - } - ); - if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { - warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { - defaultMessage: ', auto selecting first job', - }); - } - toastNotifications.addWarning(warningText); - } - - if (selectedJobIds.length > 1) { - // if more than one job or a group has been loaded from the URL - if (selectedJobIds.length > 1) { - // if more than one job, select the first job from the selection. - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard', - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else { - // if a group has been loaded - if (selectedJobIds.length > 0) { - // if the group contains valid jobs, select the first - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard', - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if (jobs.length > 0) { - // if there are no valid jobs in the group but there are valid jobs - // in the list of all jobs, select the first - setGlobalState(globalState, { selectedIds: [jobs[0].id] }); - this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true }); - } else { - // if there are no valid jobs left. - this.setState({ loading: false }); - } - } - } else if (invalidIds.length > 0 && selectedJobIds.length > 0) { - // if some ids have been filtered out because they were invalid. - // refresh the URL with the first valid id - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if (selectedJobIds.length > 0) { - // normal behavior. a job ID has been loaded from the URL - if ( - this.state.selectedJob !== undefined && - selectedJobIds[0] !== this.state.selectedJob.job_id - ) { - // Clear the detectorIndex, entities and forecast info. - appStateHandler(APP_STATE_ACTION.CLEAR); - } - this.loadForJobId(selectedJobIds[0], jobs); - } else { - if (selectedJobIds.length === 0 && jobs.length > 0) { - // no jobs were loaded from the URL, so add the first job - // from the full jobs list. - setGlobalState(globalState, { selectedIds: [jobs[0].id] }); - this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true }); - } else { - // Jobs exist, but no time series jobs. - this.setState({ loading: false }); - } - } - }) - ); - - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - this.subscriptions.add(timefilter.getTimeUpdate$().subscribe(() => this.refresh(false))); - // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', () => { @@ -1106,23 +855,6 @@ export class TimeSeriesExplorer extends React.Component { return; } - const defaultRange = this.getDefaultRangeFromState(); - - if ( - (selection.from.getTime() !== defaultRange[0].getTime() || - selection.to.getTime() !== defaultRange[1].getTime()) && - isNaN(Date.parse(selection.from)) === false && - isNaN(Date.parse(selection.to)) === false - ) { - const zoomState = { - from: selection.from.toISOString(), - to: selection.to.toISOString(), - }; - appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); - } else { - appStateHandler(APP_STATE_ACTION.UNSET_ZOOM); - } - if ( (this.contextChartSelectedInitCallDone === false && focusChartData === undefined) || zoomFromFocusLoaded.getTime() !== selection.from.getTime() || @@ -1137,7 +869,9 @@ export class TimeSeriesExplorer extends React.Component { } }), switchMap(selection => { - const { jobs, selectedJob } = this.state; + const { selectedJobIds } = this.props; + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const selectedJob = mlJobService.getJob(selectedJobIds[0]); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; @@ -1180,39 +914,267 @@ export class TimeSeriesExplorer extends React.Component { ...refreshFocusData, ...tableData, }); + const zoomState = { + from: selection.from.toISOString(), + to: selection.to.toISOString(), + }; + this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); }) ); + + this.componentDidUpdate(); + } + + /** + * returns true/false if setGlobalState has been triggered + * or returns the job id which should be loaded. + */ + checkJobSelection() { + const { jobsWithTimeRange, selectedJobIds, setGlobalState } = this.props; + + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const timeSeriesJobIds = jobs.map(j => j.id); + + // Check if any of the jobs set in the URL are not time series jobs + // (e.g. if switching to this view straight from the Anomaly Explorer). + const invalidIds = difference(selectedJobIds, timeSeriesJobIds); + const validSelectedJobIds = without(selectedJobIds, ...invalidIds); + if (invalidIds.length > 0) { + let warningText = i18n.translate( + 'xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', + { + defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, + values: { + invalidIdsCount: invalidIds.length, + invalidIds, + }, + } + ); + if (validSelectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { + warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { + defaultMessage: ', auto selecting first job', + }); + } + toastNotifications.addWarning(warningText); + } + + if (validSelectedJobIds.length > 1) { + // if more than one job or a group has been loaded from the URL + if (validSelectedJobIds.length > 1) { + // if more than one job, select the first job from the selection. + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard', + }) + ); + setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] }); + return true; + } else { + // if a group has been loaded + if (selectedJobIds.length > 0) { + // if the group contains valid jobs, select the first + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard', + }) + ); + setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] }); + return true; + } else if (jobs.length > 0) { + // if there are no valid jobs in the group but there are valid jobs + // in the list of all jobs, select the first + const jobIds = [jobs[0].id]; + const time = getTimeRangeFromSelection(jobsWithTimeRange, jobIds); + setGlobalState({ + ...{ ml: { jobIds } }, + ...(time !== undefined ? { time } : {}), + }); + return true; + } else { + // if there are no valid jobs left. + return false; + } + } + } else if (invalidIds.length > 0 && validSelectedJobIds.length > 0) { + // if some ids have been filtered out because they were invalid. + // refresh the URL with the first valid id + setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] }); + return true; + } else if (validSelectedJobIds.length > 0) { + // normal behavior. a job ID has been loaded from the URL + // Clear the detectorIndex, entities and forecast info. + return validSelectedJobIds[0]; + } else { + if (validSelectedJobIds.length === 0 && jobs.length > 0) { + // no jobs were loaded from the URL, so add the first job + // from the full jobs list. + const jobIds = [jobs[0].id]; + const time = getTimeRangeFromSelection(jobsWithTimeRange, jobIds); + setGlobalState({ + ...{ ml: { jobIds } }, + ...(time !== undefined ? { time } : {}), + }); + return true; + } else { + // Jobs exist, but no time series jobs. + return false; + } + } + } + + componentDidUpdate(previousProps) { + if ( + previousProps === undefined || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) + ) { + const update = this.checkJobSelection(); + // - true means a setGlobalState got triggered and + // we'll just wait for the next React render. + // - false means there are either no jobs or no time based jobs present. + // - if we get back a string it means we got back a job id we can load. + if (update === true) { + return; + } else if (update === false) { + this.setState({ loading: false }); + return; + } else if (typeof update === 'string') { + this.contextChartSelectedInitCallDone = false; + this.setState({ fullRefresh: false, loading: true }, () => { + this.loadForJobId(update); + }); + } + } + + if ( + this.props.bounds !== undefined && + this.props.selectedJobIds !== undefined && + (previousProps === undefined || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) || + previousProps.selectedDetectorIndex !== this.props.selectedDetectorIndex || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities)) + ) { + const entityControls = this.getControlsForDetector(); + this.loadEntityValues(entityControls); + } + + if ( + previousProps === undefined || + previousProps.selectedForecastId !== this.props.selectedForecastId + ) { + if (this.props.selectedForecastId !== undefined) { + // Ensure the forecast data will be shown if hidden previously. + this.setState({ showForecast: true }); + } + } + + if ( + previousProps === undefined || + !isEqual(previousProps.bounds, this.props.bounds) || + !isEqual(previousProps.lastRefresh, this.props.lastRefresh) || + !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || + !isEqual(previousProps.selectedForecastId, this.props.selectedForecastId) || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) || + !isEqual(previousProps.zoom, this.props.zoom) + ) { + const fullRefresh = + previousProps === undefined || + !isEqual(previousProps.bounds, this.props.bounds) || + !isEqual(previousProps.lastRefresh, this.props.lastRefresh) || + !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || + !isEqual(previousProps.selectedForecastId, this.props.selectedForecastId) || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds); + this.loadSingleMetricData(fullRefresh); + } + + if (previousProps === undefined) { + return; + } + + // Reload the anomalies table if the Interval or Threshold controls are changed. + const tableControlsListener = () => { + const { zoomFrom, zoomTo } = this.state; + if (zoomFrom !== undefined && zoomTo !== undefined) { + this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()).subscribe(res => + this.setState(res) + ); + } + }; + + if ( + previousProps.tableInterval !== this.props.tableInterval || + previousProps.tableSeverity !== this.props.tableSeverity + ) { + tableControlsListener(); + } + + if ( + this.props.autoZoomDuration === undefined || + this.props.selectedForecastId !== undefined || + this.state.contextAggregationInterval === undefined || + this.state.contextChartData === undefined || + this.state.contextChartData.length === 0 + ) { + return; + } + + const defaultRange = calculateDefaultFocusRange( + this.props.autoZoomDuration, + this.state.contextAggregationInterval, + this.state.contextChartData, + this.state.contextForecastData + ); + + const selection = { + from: this.state.zoomFrom, + to: this.state.zoomTo, + }; + + if ( + (selection.from.getTime() !== defaultRange[0].getTime() || + selection.to.getTime() !== defaultRange[1].getTime()) && + isNaN(Date.parse(selection.from)) === false && + isNaN(Date.parse(selection.to)) === false + ) { + const zoomState = { + from: selection.from.toISOString(), + to: selection.to.toISOString(), + }; + this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + } } componentWillUnmount() { this.subscriptions.unsubscribe(); this.resizeChecker.destroy(); - this.unsubscribeFromGlobalState(); } render() { - const { dateFormatTz, globalState, timefilter } = this.props; - const { autoZoomDuration, + bounds, + dateFormatTz, + lastRefresh, + selectedDetectorIndex, + selectedJobIds, + } = this.props; + + const { chartDetails, contextAggregationInterval, contextChartData, contextForecastData, dataNotChartable, - detectors, - detectorId, - entities, + entityValues, focusAggregationInterval, focusAnnotationData, focusChartData, focusForecastData, fullRefresh, hasResults, - jobs, loading, modelPlotEnabled, - selectedJob, showAnnotations, showAnnotationsCheckbox, showForecast, @@ -1228,11 +1190,6 @@ export class TimeSeriesExplorer extends React.Component { zoomToFocusLoaded, } = this.state; - const fieldNamesWithEmptyValues = entities - .filter(({ fieldValue }) => !fieldValue) - .map(({ fieldName }) => fieldName); - const arePartitioningFieldsProvided = fieldNamesWithEmptyValues.length === 0; - const chartProps = { modelPlotEnabled, contextChartData, @@ -1244,7 +1201,6 @@ export class TimeSeriesExplorer extends React.Component { focusChartData, focusForecastData, focusAggregationInterval, - skipRefresh: loading || !!get(this.props.globalState, 'ml.skipRefresh'), svgWidth, zoomFrom, zoomTo, @@ -1253,17 +1209,14 @@ export class TimeSeriesExplorer extends React.Component { autoZoomDuration, }; - const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); const jobSelectorProps = { dateFormatTz, - globalState, - jobSelectService$: this.jobSelectService$, - selectedJobIds, - selectedGroups, singleSelection: true, timeseriesOnly: true, }; + const jobs = createTimeSeriesJobData(mlJobService.jobs); + if (jobs.length === 0) { return ( @@ -1272,7 +1225,27 @@ export class TimeSeriesExplorer extends React.Component { ); } - const detectorSelectOptions = detectors.map(d => ({ + if ( + selectedJobIds === undefined || + selectedJobIds.length > 1 || + selectedDetectorIndex === undefined || + mlJobService.getJob(selectedJobIds[0]) === undefined + ) { + return ( + + ); + } + + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const entityControls = this.getControlsForDetector(); + + const fieldNamesWithEmptyValues = entityControls + .filter(({ fieldValue }) => !fieldValue) + .map(({ fieldName }) => fieldName); + + const arePartitioningFieldsProvided = fieldNamesWithEmptyValues.length === 0; + + const detectorSelectOptions = getViewableDetectors(selectedJob).map(d => ({ value: d.index, text: d.detector_description, })); @@ -1285,12 +1258,14 @@ export class TimeSeriesExplorer extends React.Component { isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) && this.previousShowAnnotations === showAnnotations && this.previousShowForecast === showForecast && - this.previousShowModelBounds === showModelBounds + this.previousShowModelBounds === showModelBounds && + this.previousLastRefresh === lastRefresh ) { renderFocusChartOnly = false; } this.previousChartProps = chartProps; + this.previousLastRefresh = lastRefresh; this.previousShowAnnotations = showAnnotations; this.previousShowForecast = showForecast; this.previousShowModelBounds = showModelBounds; @@ -1337,12 +1312,12 @@ export class TimeSeriesExplorer extends React.Component { > - {entities.map(entity => { + {entityControls.map(entity => { const entityKey = `${entity.fieldName}`; const forceSelection = !hasEmptyFieldValues && !entity.fieldValue; hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection; @@ -1350,9 +1325,11 @@ export class TimeSeriesExplorer extends React.Component { ); })} @@ -1361,9 +1338,9 @@ export class TimeSeriesExplorer extends React.Component { @@ -1386,7 +1363,7 @@ export class TimeSeriesExplorer extends React.Component { hasResults === false && ( )} @@ -1488,13 +1465,13 @@ export class TimeSeriesExplorer extends React.Component {
{showAnnotations && focusAnnotationData.length > 0 && ( @@ -1547,8 +1524,8 @@ export class TimeSeriesExplorer extends React.Component { )} - {arePartitioningFieldsProvided && jobs.length > 0 && ( - + {arePartitioningFieldsProvided && jobs.length > 0 && hasResults === true && ( + )}
); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts index 29a5facf64c0fc..a801a1c5ce6f58 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts @@ -10,13 +10,9 @@ export const APP_STATE_ACTION = { CLEAR: 'CLEAR', - GET_DETECTOR_INDEX: 'GET_DETECTOR_INDEX', SET_DETECTOR_INDEX: 'SET_DETECTOR_INDEX', - GET_ENTITIES: 'GET_ENTITIES', SET_ENTITIES: 'SET_ENTITIES', - GET_FORECAST_ID: 'GET_FORECAST_ID', SET_FORECAST_ID: 'SET_FORECAST_ID', - GET_ZOOM: 'GET_ZOOM', SET_ZOOM: 'SET_ZOOM', UNSET_ZOOM: 'UNSET_ZOOM', }; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts index 1528ac887ad76e..1b7a740d90dde1 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts @@ -46,7 +46,7 @@ export function calculateDefaultFocusRange( export function calculateInitialFocusRange( zoomState: any, contextAggregationInterval: any, - timefilter: any + bounds: any ): any; export function getAutoZoomDuration(jobs: any, selectedJob: any): any; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js index 8e8b31ede86a88..b4706e6f609dc6 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js @@ -340,14 +340,13 @@ export function calculateDefaultFocusRange( return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; } -export function calculateInitialFocusRange(zoomState, contextAggregationInterval, timefilter) { +export function calculateInitialFocusRange(zoomState, contextAggregationInterval, bounds) { if (zoomState !== undefined) { // Check that the zoom times are valid. // zoomFrom must be at or after context chart search bounds earliest, // zoomTo must be at or before context chart search bounds latest. const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); - const bounds = timefilter.getActiveBounds(); const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, true); const earliest = searchBounds.min; const latest = searchBounds.max; diff --git a/x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap deleted file mode 100644 index b93a4702b7c3d8..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`observable_utils injectObservablesAsProps() 1`] = ` - -`; - -exports[`observable_utils injectObservablesAsProps() 2`] = ` - -`; diff --git a/x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js b/x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js deleted file mode 100644 index 2ab428f979f537..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; - -import { BehaviorSubject } from 'rxjs'; - -import { initializeAppState, subscribeAppStateToObservable } from '../app_state_utils'; - -describe('ML - initializeAppState', () => { - let AppState; - - beforeEach( - ngMock.module('kibana', stateManagementConfigProvider => { - stateManagementConfigProvider.enable(); - }) - ); - - beforeEach( - ngMock.inject($injector => { - AppState = $injector.get('AppState'); - }) - ); - - it('Throws an error when called without arguments.', () => { - expect(() => initializeAppState()).to.throwError(); - }); - - it('Initializes an appstate, gets a test value.', () => { - const appState = initializeAppState(AppState, 'mlTest', { value: 10 }); - expect(appState.mlTest.value).to.be(10); - }); -}); - -describe('ML - subscribeAppStateToObservable', () => { - let AppState; - let $rootScope; - - beforeEach( - ngMock.module('kibana', stateManagementConfigProvider => { - stateManagementConfigProvider.enable(); - }) - ); - - beforeEach( - ngMock.inject($injector => { - AppState = $injector.get('AppState'); - $rootScope = $injector.get('$rootScope'); - }) - ); - - it('Initializes a custom state store, sets and gets a test value using events.', done => { - const o$ = new BehaviorSubject({ value: 10 }); - - subscribeAppStateToObservable(AppState, 'mlTest', o$, () => $rootScope.$applyAsync()); - - o$.subscribe(payload => { - const appState = new AppState(); - appState.fetch(); - - expect(payload.value).to.be(10); - expect(appState.mlTest.value).to.be(10); - - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts deleted file mode 100644 index 454ea55210dccc..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Observable } from 'rxjs'; - -export const initializeAppState: (AppState: any, stateName: any, defaultState: any) => any; - -export const subscribeAppStateToObservable: ( - AppState: any, - appStateName: string, - o$: Observable, - callback: (payload: any) => void -) => any; diff --git a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js b/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js deleted file mode 100644 index 2875a6fa3ce19e..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js +++ /dev/null @@ -1,70 +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 { cloneDeep, isEqual } from 'lodash'; - -import { distinctUntilChanged } from 'rxjs/operators'; - -function hasEqualKeys(a, b) { - return isEqual(Object.keys(a).sort(), Object.keys(b).sort()); -} - -export function initializeAppState(AppState, stateName, defaultState) { - const appState = new AppState(); - appState.fetch(); - - // Store the state to the AppState so that it's - // restored on page refresh. - if (appState[stateName] === undefined) { - appState[stateName] = cloneDeep(defaultState); - appState.save(); - } - - // if defaultState isn't defined or if defaultState matches the current value - // stored in the URL in appState then return appState as is. - if (defaultState === undefined || appState[stateName] === defaultState) { - return appState; - } - - // If defaultState is defined, check if the keys of the defaultState - // match the one from appState, if not, fall back to the defaultState. - // If we didn't do this, the structure of an out-of-date appState - // might break some follow up code. Note that this will not catch any - // deeper nested inconsistencies. this does two checks: - // - if defaultState is an object, check if current appState has the same keys. - // - if it's not an object, check if defaultState and current appState are of the same type. - if ( - (typeof defaultState === 'object' && !hasEqualKeys(defaultState, appState[stateName])) || - typeof defaultState !== typeof appState[stateName] - ) { - appState[stateName] = cloneDeep(defaultState); - appState.save(); - } - - return appState; -} - -// Some components like the show-chart-checkbox or severity/interval-dropdowns -// emit their state change to an observable. This utility function can be used -// to persist these state changes to AppState and save the state to the url. -// distinctUntilChanged() makes sure the callback is only triggered upon changes -// of the state and filters consecutive triggers of the same value. -export function subscribeAppStateToObservable(AppState, appStateName, o$, callback) { - const appState = initializeAppState(AppState, appStateName, o$.getValue()); - - o$.next(appState[appStateName]); - - const subscription = o$.pipe(distinctUntilChanged()).subscribe(payload => { - appState.fetch(); - appState[appStateName] = payload; - appState.save(); - if (typeof callback === 'function') { - callback(payload); - } - }); - - return subscription; -} diff --git a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx b/x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx deleted file mode 100644 index c95824fc5dc4d9..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { shallow } from 'enzyme'; -import React, { ComponentType } from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { injectObservablesAsProps } from './observable_utils'; - -interface Props { - testProp: string; -} - -describe('observable_utils', () => { - test('injectObservablesAsProps()', () => { - // an observable that allows us to trigger updating some text. - const observable$ = new BehaviorSubject('initial text'); - - // a simple stateless component that just renders some text - const TestComponent: React.FC = ({ testProp }) => { - return {testProp}; - }; - - // injectObservablesAsProps wraps the observable in a new component - const ObservableComponent = injectObservablesAsProps( - { testProp: observable$ }, - (TestComponent as any) as ComponentType - ); - - const wrapper = shallow(); - - // the component should render with "initial text" - expect(wrapper).toMatchSnapshot(); - - observable$.next('updated text'); - - // the component should render with "updated text" - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx b/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx deleted file mode 100644 index 4b8027260ab9a6..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx +++ /dev/null @@ -1,67 +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 { isEqual } from 'lodash'; -import React, { Component, ComponentType } from 'react'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged } from 'rxjs/operators'; -import { Dictionary } from '../../../common/types/common'; - -// Sets up a ObservableComponent which subscribes to given observable updates and -// and passes them on as prop values to the given WrappedComponent. -// This give us the benefit of abstracting away the need to set up subscribers and callbacks, -// and the passed down props can be used in pure/functional components without -// the need for their own state management. -export function injectObservablesAsProps( - observables: Dictionary>, - WrappedComponent: ComponentType -): ComponentType { - const observableKeys = Object.keys(observables); - - class ObservableComponent extends Component { - public state = observableKeys.reduce((reducedState: Dictionary, key: string) => { - reducedState[key] = observables[key].value; - return reducedState; - }, {}); - - public subscriptions = {} as Dictionary; - - public componentDidMount() { - observableKeys.forEach(k => { - this.subscriptions[k] = observables[k] - .pipe(distinctUntilChanged(isEqual)) - .subscribe(v => this.setState({ [k]: v })); - }); - } - - public componentWillUnmount() { - Object.keys(this.subscriptions).forEach((key: string) => - this.subscriptions[key].unsubscribe() - ); - } - - public render() { - // All injected observables are expected to provide initial state. - // If an observable has undefined as its current value, rendering - // the wrapped component will be skipped. - if ( - Object.keys(this.state) - .map(k => this.state[k]) - .some(v => v === undefined) - ) { - return null; - } - - return ( - - {this.props.children} - - ); - } - } - - return ObservableComponent as ComponentType; -} diff --git a/x-pack/legacy/plugins/ml/public/application/util/url_state.ts b/x-pack/legacy/plugins/ml/public/application/util/url_state.ts new file mode 100644 index 00000000000000..4402155815a5b1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/util/url_state.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { isEqual } from 'lodash'; +// @ts-ignore +import queryString from 'query-string'; +import { decode, encode } from 'rison-node'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { Dictionary } from '../../../common/types/common'; + +import { getNestedProperty } from './object_utils'; + +export type SetUrlState = (attribute: string | Dictionary, value?: any) => void; +export type UrlState = [Dictionary, SetUrlState]; + +function getUrlState(search: string) { + const urlState: Dictionary = {}; + const parsedQueryString = queryString.parse(search); + + try { + Object.keys(parsedQueryString).forEach(a => { + urlState[a] = decode(parsedQueryString[a]) as Dictionary; + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Could not read url state', error); + } + + return urlState; +} + +// Compared to the original appState/globalState, +// this no longer makes use of fetch/save methods. +// - Reading from `location.search` is the successor of `fetch`. +// - `history.push()` is the successor of `save`. +// - The exposed state and set call make use of the above and make sure that +// different urlStates(e.g. `_a` / `_g`) don't overwrite each other. +export const useUrlState = (accessor: string): UrlState => { + const history = useHistory(); + const { search } = useLocation(); + + const setUrlState = useCallback( + (attribute: string | Dictionary, value?: any) => { + const urlState = getUrlState(search); + const parsedQueryString = queryString.parse(search); + + if (!Object.prototype.hasOwnProperty.call(urlState, accessor)) { + urlState[accessor] = {}; + } + + if (typeof attribute === 'string') { + if (isEqual(getNestedProperty(urlState, `${accessor}.${attribute}`), value)) { + return; + } + + urlState[accessor][attribute] = value; + } else { + const attributes = attribute; + Object.keys(attributes).forEach(a => { + urlState[accessor][a] = attributes[a]; + }); + } + + try { + const oldLocationSearch = queryString.stringify(parsedQueryString, { encode: false }); + + Object.keys(urlState).forEach(a => { + parsedQueryString[a] = encode(urlState[a]); + }); + const newLocationSearch = queryString.stringify(parsedQueryString, { encode: false }); + + if (oldLocationSearch !== newLocationSearch) { + history.push({ + search: queryString.stringify(parsedQueryString), + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Could not save url state', error); + } + }, + [search] + ); + + return [getUrlState(search)[accessor], setUrlState]; +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap new file mode 100644 index 00000000000000..ea9d312413168b --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bytes Usage should format correctly with only usedBytes 1`] = ` + +
+ 50.0 B +
+
+`; + +exports[`Bytes Usage should format correctly with used and max bytes 1`] = ` + +
+ 50.0 B / 100.0 B +
+
+`; + +exports[`BytesPercentageUsage should format correctly with used bytes and max bytes 1`] = ` + +
+ 50.00% +
+
+
+ 50.0 B / 100.0 B +
+
+
+`; + +exports[`BytesPercentageUsage should return zero bytes if both parameters are not present 1`] = ` +
+ 0 +
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js new file mode 100644 index 00000000000000..fea8f0001540af --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderWithIntl } from '../../../../../../../../test_utils/enzyme_helpers'; +import { BytesUsage, BytesPercentageUsage } from '../helpers'; + +describe('Bytes Usage', () => { + it('should format correctly with used and max bytes', () => { + const props = { + usedBytes: 50, + maxBytes: 100, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); + + it('should format correctly with only usedBytes', () => { + const props = { + usedBytes: 50, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); +}); + +describe('BytesPercentageUsage', () => { + it('should format correctly with used bytes and max bytes', () => { + const props = { + usedBytes: 50, + maxBytes: 100, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); + it('should return zero bytes if both parameters are not present', () => { + const props = { + usedBytes: 50, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js index 3ba04359c2672b..84dc13e9da1de3 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js @@ -8,11 +8,7 @@ import React from 'react'; import moment from 'moment'; import { get } from 'lodash'; import { formatMetric } from 'plugins/monitoring/lib/format_number'; -import { - ClusterItemContainer, - BytesPercentageUsage, - DisabledIfNoDataAndInSetupModeLink, -} from './helpers'; +import { ClusterItemContainer, BytesUsage, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -153,7 +149,7 @@ export function ApmPanel(props) { /> - + diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index fc23110f940e88..7b08c89f538814 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -10,7 +10,6 @@ import { formatNumber } from 'plugins/monitoring/lib/format_number'; import { ClusterItemContainer, HealthStatusIndicator, - BytesUsage, BytesPercentageUsage, DisabledIfNoDataAndInSetupModeLink, } from './helpers'; @@ -291,7 +290,7 @@ export function ElasticsearchPanel(props) { /> - diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js index ae7cc1b4e965c1..0d9290225cd5f0 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js @@ -6,7 +6,7 @@ import React from 'react'; import { get } from 'lodash'; -import { formatBytesUsage, formatPercentageUsage } from 'plugins/monitoring/lib/format_number'; +import { formatBytesUsage, formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { EuiSpacer, EuiFlexItem, @@ -88,10 +88,13 @@ export function BytesUsage({ usedBytes, maxBytes }) { if (usedBytes && maxBytes) { return ( - {formatPercentageUsage(usedBytes, maxBytes)} - - {formatBytesUsage(usedBytes, maxBytes)} - + {formatBytesUsage(usedBytes, maxBytes)} + + ); + } else if (usedBytes) { + return ( + + {formatNumber(usedBytes, 'byte')} ); } diff --git a/x-pack/legacy/plugins/oss_telemetry/index.ts b/x-pack/legacy/plugins/oss_telemetry/index.ts index 8b16c7cf13cada..fce861c7d3f460 100644 --- a/x-pack/legacy/plugins/oss_telemetry/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/index.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, PluginInitializerContext } from 'kibana/server'; +import { Logger, PluginInitializerContext, CoreStart } from 'kibana/server'; +import { Legacy } from 'kibana'; import { PLUGIN_ID } from './constants'; import { OssTelemetryPlugin } from './server/plugin'; import { LegacyPluginInitializer } from '../../../../src/legacy/plugin_discovery/types'; +import { getTaskManagerSetup, getTaskManagerStart } from '../task_manager/server'; export const ossTelemetry: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ @@ -15,7 +17,7 @@ export const ossTelemetry: LegacyPluginInitializer = kibana => { require: ['elasticsearch', 'xpack_main'], configPrefix: 'xpack.oss_telemetry', - init(server) { + init(server: Legacy.Server) { const plugin = new OssTelemetryPlugin({ logger: { get: () => @@ -27,14 +29,24 @@ export const ossTelemetry: LegacyPluginInitializer = kibana => { } as Logger), }, } as PluginInitializerContext); - plugin.setup(server.newPlatform.setup.core, { + + const deps = { usageCollection: server.newPlatform.setup.plugins.usageCollection, - taskManager: server.plugins.task_manager, __LEGACY: { config: server.config(), xpackMainStatus: ((server.plugins.xpack_main as unknown) as { status: any }).status .plugin, }, + }; + + plugin.setup(server.newPlatform.setup.core, { + ...deps, + taskManager: getTaskManagerSetup(server), + }); + + plugin.start((server.newPlatform.setup.core as unknown) as CoreStart, { + ...deps, + taskManager: getTaskManagerStart(server), }); }, }); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts index 3b47099fdc4624..9d547c1b22099b 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts @@ -5,8 +5,8 @@ */ import { registerVisualizationsCollector } from './visualizations/register_usage_collector'; -import { OssTelemetrySetupDependencies } from '../../plugin'; +import { OssTelemetryStartDependencies } from '../../plugin'; -export function registerCollectors(deps: OssTelemetrySetupDependencies) { +export function registerCollectors(deps: OssTelemetryStartDependencies) { registerVisualizationsCollector(deps.usageCollection, deps.taskManager); } diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts index ec352666466501..ce106d1a64fd65 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts @@ -4,29 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMockTaskFetch, getMockTaskManager } from '../../../../test_utils'; +import { + getMockTaskFetch, + getMockThrowingTaskFetch, + getMockTaskInstance, +} from '../../../../test_utils'; +import { taskManagerMock } from '../../../../../../../plugins/task_manager/server/task_manager.mock'; import { getUsageCollector } from './get_usage_collector'; describe('getVisualizationsCollector#fetch', () => { test('can return empty stats', async () => { - const { type, fetch } = getUsageCollector(getMockTaskManager()); + const { type, fetch } = getUsageCollector(taskManagerMock.start(getMockTaskFetch())); expect(type).toBe('visualization_types'); const fetchResult = await fetch(); expect(fetchResult).toEqual({}); }); test('provides known stats', async () => { - const mockTaskFetch = getMockTaskFetch([ - { - state: { - runs: 1, - stats: { comic_books: { total: 16, max: 12, min: 2, avg: 6 } }, - }, - taskType: 'test', - params: {}, - }, - ]); - const { type, fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { type, fetch } = getUsageCollector( + taskManagerMock.start( + getMockTaskFetch([ + getMockTaskInstance({ + state: { + runs: 1, + stats: { comic_books: { total: 16, max: 12, min: 2, avg: 6 } }, + }, + taskType: 'test', + params: {}, + }), + ]) + ) + ); expect(type).toBe('visualization_types'); const fetchResult = await fetch(); expect(fetchResult).toEqual({ comic_books: { avg: 6, max: 12, min: 2, total: 16 } }); @@ -34,20 +42,21 @@ describe('getVisualizationsCollector#fetch', () => { describe('Error handling', () => { test('Silently handles Task Manager NotInitialized', async () => { - const mockTaskFetch = jest.fn(() => { - throw new Error('NotInitialized taskManager is still waiting for plugins to load'); - }); - const { fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { fetch } = getUsageCollector( + taskManagerMock.start( + getMockThrowingTaskFetch( + new Error('NotInitialized taskManager is still waiting for plugins to load') + ) + ) + ); const result = await fetch(); expect(result).toBe(undefined); }); // In real life, the CollectorSet calls fetch and handles errors test('defers the errors', async () => { - const mockTaskFetch = jest.fn(() => { - throw new Error('BOOM'); - }); - - const { fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { fetch } = getUsageCollector( + taskManagerMock.start(getMockThrowingTaskFetch(new Error('BOOM'))) + ); await expect(fetch()).rejects.toThrowErrorMatchingInlineSnapshot(`"BOOM"`); }); }); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts index 11dbddc00f8300..bc0d10860a667b 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts @@ -5,15 +5,15 @@ */ import { get } from 'lodash'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../../task_manager/server/plugin'; import { PLUGIN_ID, VIS_TELEMETRY_TASK, VIS_USAGE_TYPE } from '../../../../constants'; +import { TaskManagerStartContract } from '../../../../../../../plugins/task_manager/server'; -async function isTaskManagerReady(taskManager: TaskManagerPluginSetupContract | undefined) { +async function isTaskManagerReady(taskManager?: TaskManagerStartContract) { const result = await fetch(taskManager); return result !== null; } -async function fetch(taskManager: TaskManagerPluginSetupContract | undefined) { +async function fetch(taskManager?: TaskManagerStartContract) { if (!taskManager) { return null; } @@ -38,7 +38,7 @@ async function fetch(taskManager: TaskManagerPluginSetupContract | undefined) { return docs; } -export function getUsageCollector(taskManager: TaskManagerPluginSetupContract | undefined) { +export function getUsageCollector(taskManager?: TaskManagerStartContract) { let isCollectorReady = false; async function determineIfTaskManagerIsReady() { let isReady = false; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts index 46b86091c9db15..657f1c725f4e06 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts @@ -5,12 +5,12 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../../task_manager/server/plugin'; +import { TaskManagerStartContract } from '../../../../../../../plugins/task_manager/server'; import { getUsageCollector } from './get_usage_collector'; export function registerVisualizationsCollector( collectorSet: UsageCollectionSetup, - taskManager: TaskManagerPluginSetupContract | undefined + taskManager?: TaskManagerStartContract ): void { const collector = collectorSet.makeUsageCollector(getUsageCollector(taskManager)); collectorSet.registerCollector(collector); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts index c9714306d73c52..cf7295f67a2317 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts @@ -5,12 +5,15 @@ */ import { CoreSetup, Logger } from 'kibana/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../task_manager/server/plugin'; import { PLUGIN_ID, VIS_TELEMETRY_TASK } from '../../../constants'; import { visualizationsTaskRunner } from './visualizations/task_runner'; import KbnServer from '../../../../../../../src/legacy/server/kbn_server'; import { LegacyConfig } from '../../plugin'; -import { TaskInstance } from '../../../../task_manager/server'; +import { + TaskInstance, + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../../../plugins/task_manager/server'; export function registerTasks({ taskManager, @@ -18,7 +21,7 @@ export function registerTasks({ elasticsearch, config, }: { - taskManager?: TaskManagerPluginSetupContract; + taskManager?: TaskManagerSetupContract; logger: Logger; elasticsearch: CoreSetup['elasticsearch']; config: LegacyConfig; @@ -46,7 +49,7 @@ export function scheduleTasks({ xpackMainStatus, logger, }: { - taskManager?: TaskManagerPluginSetupContract; + taskManager?: TaskManagerStartContract; xpackMainStatus: { kbnServer: KbnServer }; logger: Logger; }) { diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts index af3eed2496f5d5..ef03e857de8efc 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts @@ -12,7 +12,7 @@ import { getMockTaskInstance, } from '../../../../test_utils'; import { visualizationsTaskRunner } from './task_runner'; -import { TaskInstance } from '../../../../../task_manager/server'; +import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; describe('visualizationsTaskRunner', () => { let mockTaskInstance: TaskInstance; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts index 8fb2da5627ee85..0b7b301df12bf8 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts @@ -8,7 +8,7 @@ import _, { countBy, groupBy, mapValues } from 'lodash'; import { APICaller, CoreSetup } from 'kibana/server'; import { getNextMidnight } from '../../get_next_midnight'; import { VisState } from '../../../../../../../../src/legacy/core_plugins/visualizations/public'; -import { TaskInstance } from '../../../../../task_manager/server'; +import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; import { ESSearchHit } from '../../../../../apm/typings/elasticsearch'; import { LegacyConfig } from '../../../plugin'; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts b/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts index 209c73eb0eb62d..0aac319cf58180 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../task_manager/server/plugin'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; import { registerCollectors } from './lib/collectors'; import { registerTasks, scheduleTasks } from './lib/tasks'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; @@ -15,13 +18,18 @@ export interface LegacyConfig { get: (key: string) => string | number | boolean; } -export interface OssTelemetrySetupDependencies { +interface OssTelemetryDependencies { usageCollection: UsageCollectionSetup; __LEGACY: { config: LegacyConfig; xpackMainStatus: { kbnServer: KbnServer }; }; - taskManager?: TaskManagerPluginSetupContract; +} +export interface OssTelemetrySetupDependencies extends OssTelemetryDependencies { + taskManager?: TaskManagerSetupContract; +} +export interface OssTelemetryStartDependencies extends OssTelemetryDependencies { + taskManager?: TaskManagerStartContract; } export class OssTelemetryPlugin implements Plugin { @@ -32,19 +40,20 @@ export class OssTelemetryPlugin implements Plugin { } public setup(core: CoreSetup, deps: OssTelemetrySetupDependencies) { - registerCollectors(deps); registerTasks({ taskManager: deps.taskManager, logger: this.logger, elasticsearch: core.elasticsearch, config: deps.__LEGACY.config, }); + } + + public start(core: CoreStart, deps: OssTelemetryStartDependencies) { + registerCollectors(deps); scheduleTasks({ taskManager: deps.taskManager, xpackMainStatus: deps.__LEGACY.xpackMainStatus, logger: this.logger, }); } - - public start() {} } diff --git a/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts b/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts index c6046eb648bf42..0695fda3c2c948 100644 --- a/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts @@ -6,13 +6,28 @@ import { APICaller, CoreSetup } from 'kibana/server'; -import { TaskInstance } from '../../task_manager/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../task_manager/server/plugin'; +import { + ConcreteTaskInstance, + TaskStatus, + TaskManagerStartContract, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../plugins/task_manager/server'; -export const getMockTaskInstance = (): TaskInstance => ({ +export const getMockTaskInstance = ( + overrides: Partial = {} +): ConcreteTaskInstance => ({ state: { runs: 0, stats: {} }, taskType: 'test', params: {}, + id: '', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + ownerId: null, + ...overrides, }); const defaultMockSavedObjects = [ @@ -38,8 +53,24 @@ export const getMockCallWithInternal = (hits: unknown[] = defaultMockSavedObject }) as unknown) as APICaller; }; -export const getMockTaskFetch = (docs: TaskInstance[] = defaultMockTaskDocs) => { - return () => Promise.resolve({ docs }); +export const getMockTaskFetch = ( + docs: ConcreteTaskInstance[] = defaultMockTaskDocs +): Partial> => { + return { + fetch: jest.fn(fetchOpts => { + return Promise.resolve({ docs, searchAfter: [] }); + }), + } as Partial>; +}; + +export const getMockThrowingTaskFetch = ( + throws: Error +): Partial> => { + return { + fetch: jest.fn(fetchOpts => { + throw throws; + }), + } as Partial>; }; export const getMockConfig = () => { @@ -48,13 +79,6 @@ export const getMockConfig = () => { }; }; -export const getMockTaskManager = (fetch: any = getMockTaskFetch()) => - (({ - registerTaskDefinitions: () => undefined, - ensureScheduled: () => Promise.resolve(), - fetch, - } as unknown) as TaskManagerPluginSetupContract); - export const getCluster = () => ({ callWithInternalUser: getMockCallWithInternal(), }); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index faa27bfb2d6eae..ef0ab37738362d 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -59,7 +59,7 @@ export const reporting = (kibana: any) => { defaultMessage: `Custom image to use in the PDF's footer`, }), type: 'image', - options: { + validation: { maxSize: { length: kbToBase64Length(200), description: '200 kB', diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts index 73711f1434d5ff..3853e703a7c071 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts @@ -18,9 +18,16 @@ import { import { DEFAULT_TIMEOUT, loginAndWaitForPage, waitForTableLoad } from '../../lib/util/helpers'; describe('Pagination', () => { - it('pagination updates results and page number', () => { + before(() => { loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); waitForTableLoad(UNCOMMON_PROCCESSES_TABLE); + }); + + afterEach(() => { + cy.get(getPageButtonSelector(0)).click({ force: true }); + }); + + it('pagination updates results and page number', () => { cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); cy.get(getDraggableField('process.name')) @@ -42,8 +49,6 @@ describe('Pagination', () => { }); it('pagination keeps track of page results when tabs change', () => { - loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); - waitForTableLoad(UNCOMMON_PROCCESSES_TABLE); cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); let thirdPageResult: string; cy.get(getPageButtonSelector(2)).click({ force: true }); @@ -78,7 +83,6 @@ describe('Pagination', () => { * when we figure out a way to really mock the data, we should come back to it */ it('pagination resets results and page number to first page when refresh is clicked', () => { - loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); cy.get(NUMBERED_PAGINATION, { timeout: DEFAULT_TIMEOUT }); cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); // let firstResult: string; diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index b39d43cc01b425..771e220a2a0b3e 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -15,10 +15,10 @@ import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { getIndexPatternTitleIdMapping } from '../../hooks/api/helpers'; import { useIndexPatterns } from '../../hooks/use_index_patterns'; import { Loader } from '../loader'; -import { useStateToaster } from '../toasters'; +import { displayErrorToast, useStateToaster } from '../toasters'; import { Embeddable } from './embeddable'; import { EmbeddableHeader } from './embeddable_header'; -import { createEmbeddable, displayErrorToast } from './embedded_map_helpers'; +import { createEmbeddable } from './embedded_map_helpers'; import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt'; import { MapToolTip } from './map_tool_tip/map_tool_tip'; import * as i18n from './translations'; @@ -134,7 +134,7 @@ export const EmbeddedMapComponent = ({ } } catch (e) { if (isSubscribed) { - displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, e.message, dispatchToaster); + displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, [e.message], dispatchToaster); setIsError(true); } } diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx index 4e5fcee4398279..a83e8377deeb68 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx @@ -4,19 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createEmbeddable, displayErrorToast } from './embedded_map_helpers'; +import { createEmbeddable } from './embedded_map_helpers'; import { createUiNewPlatformMock } from 'ui/new_platform/__mocks__/helpers'; import { createPortalNode } from 'react-reverse-portal'; jest.mock('ui/new_platform'); -jest.mock('uuid', () => { - return { - v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), - v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), - }; -}); - const { npStart } = createUiNewPlatformMock(); npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({ createFromState: () => ({ @@ -25,24 +18,6 @@ npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(( })); describe('embedded_map_helpers', () => { - describe('displayErrorToast', () => { - test('dispatches toast with correct title and message', () => { - const mockToast = { - toast: { - color: 'danger', - errors: ['message'], - iconType: 'alert', - id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', - title: 'Title', - }, - type: 'addToaster', - }; - const dispatchToasterMock = jest.fn(); - displayErrorToast('Title', 'message', dispatchToasterMock); - expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockToast); - }); - }); - describe('createEmbeddable', () => { test('attaches refresh action', async () => { const setQueryMock = jest.fn(); diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx index b9a9df9824eee4..838e74cc5624c8 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx @@ -7,7 +7,6 @@ import uuid from 'uuid'; import React from 'react'; import { OutPortal, PortalNode } from 'react-reverse-portal'; -import { ActionToaster, AppToast } from '../toasters'; import { ViewMode } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; import { IndexPatternMapping, @@ -22,31 +21,6 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../../maps/common/constants'; import * as i18n from './translations'; import { Query, esFilters } from '../../../../../../../src/plugins/data/public'; -/** - * Displays an error toast for the provided title and message - * - * @param errorTitle Title of error to display in toaster and modal - * @param errorMessage Message to display in error modal when clicked - * @param dispatchToaster provided by useStateToaster() - */ -export const displayErrorToast = ( - errorTitle: string, - errorMessage: string, - dispatchToaster: React.Dispatch -) => { - const toast: AppToast = { - id: uuid.v4(), - title: errorTitle, - color: 'danger', - iconType: 'alert', - errors: [errorMessage], - }; - dispatchToaster({ - type: 'addToaster', - toast, - }); -}; - /** * Creates MapEmbeddable with provided initial configuration * diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx index 5ef5a5ab31d4bd..9338eb9f0fabd7 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx @@ -8,7 +8,20 @@ import { cloneDeep, set } from 'lodash/fp'; import { mount } from 'enzyme'; import React, { useEffect } from 'react'; -import { AppToast, useStateToaster, ManageGlobalToaster, GlobalToaster } from '.'; +import { + AppToast, + useStateToaster, + ManageGlobalToaster, + GlobalToaster, + displayErrorToast, +} from '.'; + +jest.mock('uuid', () => { + return { + v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), + v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), + }; +}); const mockToast: AppToast = { color: 'danger', @@ -270,4 +283,22 @@ describe('Toaster', () => { expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test'); }); }); + + describe('displayErrorToast', () => { + test('dispatches toast with correct title and message', () => { + const mockErrorToast = { + toast: { + color: 'danger', + errors: ['message'], + iconType: 'alert', + id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', + title: 'Title', + }, + type: 'addToaster', + }; + const dispatchToasterMock = jest.fn(); + displayErrorToast('Title', ['message'], dispatchToasterMock); + expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockErrorToast); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx index 27d59d429913cc..7098e618aeb55a 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx @@ -8,6 +8,7 @@ import { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from import { noop } from 'lodash/fp'; import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react'; import styled from 'styled-components'; +import uuid from 'uuid'; import { ModalAllErrors } from './modal_all_errors'; import * as i18n from './translations'; @@ -122,3 +123,28 @@ const ErrorToastContainer = styled.div` `; ErrorToastContainer.displayName = 'ErrorToastContainer'; + +/** + * Displays an error toast for the provided title and message + * + * @param errorTitle Title of error to display in toaster and modal + * @param errorMessages Message to display in error modal when clicked + * @param dispatchToaster provided by useStateToaster() + */ +export const displayErrorToast = ( + errorTitle: string, + errorMessages: string[], + dispatchToaster: React.Dispatch +) => { + const toast: AppToast = { + id: uuid.v4(), + title: errorTitle, + color: 'danger', + iconType: 'alert', + errors: errorMessages, + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index b69a8de29e0474..8f8b66ae35a3b5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,6 +16,7 @@ import { Rule, FetchRuleProps, BasicFetchProps, + RuleError, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; import { @@ -122,50 +123,50 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => { - const requests = ids.map(id => - fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + { method: 'PUT', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - body: JSON.stringify({ id, enabled }), - }) + body: JSON.stringify(ids.map(id => ({ id, enabled }))), + } ); - const responses = await Promise.all(requests); - await responses.map(response => throwIfNotOk(response)); - return Promise.all( - responses.map>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** * Deletes provided Rule ID's * * @param ids array of Rule ID's (not rule_id) to delete + * + * @throws An error if response is not OK */ -export const deleteRules = async ({ ids }: DeleteRulesProps): Promise => { - // TODO: Don't delete if immutable! - const requests = ids.map(id => - fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}?id=${id}`, { +export const deleteRules = async ({ ids }: DeleteRulesProps): Promise> => { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, + { method: 'DELETE', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - }) + body: JSON.stringify(ids.map(id => ({ id }))), + } ); - const responses = await Promise.all(requests); - await responses.map(response => throwIfNotOk(response)); - return Promise.all( - responses.map>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index a329d96d444aac..147b04567f6c7c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -78,9 +78,11 @@ export const RuleSchema = t.intersection([ updated_by: t.string, }), t.partial({ + output_index: t.string, saved_id: t.string, timeline_id: t.string, timeline_title: t.string, + version: t.number, }), ]); @@ -89,6 +91,16 @@ export const RulesSchema = t.array(RuleSchema); export type Rule = t.TypeOf; export type Rules = t.TypeOf; +export interface RuleError { + rule_id: string; + error: { status_code: number; message: string }; +} + +export interface RuleResponseBuckets { + rules: Rule[]; + errors: RuleError[]; +} + export interface PaginationOptions { page: number; perPage: number; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts new file mode 100644 index 00000000000000..3762cb0a4ba075 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; +import { TableData } from '../../types'; + +export const mockRule = (id: string): Rule => ({ + created_at: '2020-01-10T21:11:45.839Z', + updated_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id, + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 21, + name: 'Home Grown!', + query: '', + references: [], + saved_id: "Garrett's IP", + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + meta: { from: '0m' }, + severity: 'low', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'saved_query', + threats: [], + version: 1, +}); + +export const mockRuleError = (id: string): RuleError => ({ + rule_id: id, + error: { status_code: 404, message: `id: "${id}" not found` }, +}); + +export const mockRules: Rule[] = [ + mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'), + mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'), +]; +export const mockTableData: TableData[] = [ + { + activate: true, + id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61', + immutable: false, + isLoading: false, + lastCompletedRun: undefined, + lastResponse: { type: '—' }, + method: 'saved_query', + rule: { + href: '#/detection-engine/rules/id/abe6c564-050d-45a5-aaf0-386c37dd1f61', + name: 'Home Grown!', + status: 'Status Placeholder', + }, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + severity: 'low', + sourceRule: { + created_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61', + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { from: '0m' }, + name: 'Home Grown!', + output_index: '.siem-signals-default', + query: '', + references: [], + risk_score: 21, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + saved_id: "Garrett's IP", + severity: 'low', + tags: [], + threats: [], + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + to: 'now', + type: 'saved_query', + updated_at: '2020-01-10T21:11:45.839Z', + updated_by: 'elastic', + version: 1, + }, + tags: [], + }, + { + activate: true, + id: '63f06f34-c181-4b2d-af35-f2ace572a1ee', + immutable: false, + isLoading: false, + lastCompletedRun: undefined, + lastResponse: { type: '—' }, + method: 'saved_query', + rule: { + href: '#/detection-engine/rules/id/63f06f34-c181-4b2d-af35-f2ace572a1ee', + name: 'Home Grown!', + status: 'Status Placeholder', + }, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + severity: 'low', + sourceRule: { + created_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id: '63f06f34-c181-4b2d-af35-f2ace572a1ee', + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { from: '0m' }, + name: 'Home Grown!', + output_index: '.siem-signals-default', + query: '', + references: [], + risk_score: 21, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + saved_id: "Garrett's IP", + severity: 'low', + tags: [], + threats: [], + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + to: 'now', + type: 'saved_query', + updated_at: '2020-01-10T21:11:45.839Z', + updated_by: 'elastic', + version: 1, + }, + tags: [], + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index 469745262d9446..24e3cfde1e4481 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -5,7 +5,7 @@ */ import * as H from 'history'; -import React from 'react'; +import React, { Dispatch } from 'react'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { @@ -16,40 +16,92 @@ import { } from '../../../../containers/detection_engine/rules'; import { Action } from './reducer'; +import { ActionToaster, displayErrorToast } from '../../../../components/toasters'; + +import * as i18n from '../translations'; +import { bucketRulesResponse } from './helpers'; + export const editRuleAction = (rule: Rule, history: H.History) => { history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`); }; export const runRuleAction = () => {}; -export const duplicateRuleAction = async (rule: Rule, dispatch: React.Dispatch) => { - dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true }); - const duplicatedRule = await duplicateRules({ rules: [rule] }); - dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false }); - dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id }); +export const duplicateRuleAction = async ( + rule: Rule, + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true }); + const duplicatedRule = await duplicateRules({ rules: [rule] }); + dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false }); + dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id }); + } catch (e) { + displayErrorToast(i18n.DUPLICATE_RULE_ERROR, [e.message], dispatchToaster); + } }; export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch) => { dispatch({ type: 'setExportPayload', exportPayload: rules }); }; -export const deleteRulesAction = async (ids: string[], dispatch: React.Dispatch) => { - dispatch({ type: 'updateLoading', ids, isLoading: true }); - const deletedRules = await deleteRules({ ids }); - dispatch({ type: 'deleteRules', rules: deletedRules }); +export const deleteRulesAction = async ( + ids: string[], + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'updateLoading', ids, isLoading: true }); + + const response = await deleteRules({ ids }); + const { rules, errors } = bucketRulesResponse(response); + + dispatch({ type: 'deleteRules', rules }); + + if (errors.length > 0) { + displayErrorToast( + i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length), + errors.map(e => e.error.message), + dispatchToaster + ); + } + } catch (e) { + displayErrorToast( + i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length), + [e.message], + dispatchToaster + ); + } }; export const enableRulesAction = async ( ids: string[], enabled: boolean, - dispatch: React.Dispatch + dispatch: React.Dispatch, + dispatchToaster: Dispatch ) => { + const errorTitle = enabled + ? i18n.BATCH_ACTION_ACTIVATE_SELECTED_ERROR(ids.length) + : i18n.BATCH_ACTION_DEACTIVATE_SELECTED_ERROR(ids.length); + try { dispatch({ type: 'updateLoading', ids, isLoading: true }); - const updatedRules = await enableRules({ ids, enabled }); - dispatch({ type: 'updateRules', rules: updatedRules }); - } catch { - // TODO Add error toast support to actions (and @throw jsdoc to api calls) + + const response = await enableRules({ ids, enabled }); + const { rules, errors } = bucketRulesResponse(response); + + dispatch({ type: 'updateRules', rules }); + + if (errors.length > 0) { + displayErrorToast( + errorTitle, + errors.map(e => e.error.message), + dispatchToaster + ); + } + } catch (e) { + displayErrorToast(errorTitle, [e.message], dispatchToaster); dispatch({ type: 'updateLoading', ids, isLoading: false }); } }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx index 72d38454ad9bc3..3356ef101677d0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx @@ -5,20 +5,23 @@ */ import { EuiContextMenuItem } from '@elastic/eui'; -import React from 'react'; +import React, { Dispatch } from 'react'; import * as i18n from '../translations'; import { TableData } from '../types'; import { Action } from './reducer'; import { deleteRulesAction, enableRulesAction, exportRulesAction } from './actions'; +import { ActionToaster } from '../../../../components/toasters'; export const getBatchItems = ( selectedState: TableData[], - dispatch: React.Dispatch, + dispatch: Dispatch, + dispatchToaster: Dispatch, closePopover: () => void ) => { const containsEnabled = selectedState.some(v => v.activate); const containsDisabled = selectedState.some(v => !v.activate); const containsLoading = selectedState.some(v => v.isLoading); + const containsImmutable = selectedState.some(v => v.immutable); return [ { closePopover(); const deactivatedIds = selectedState.filter(s => !s.activate).map(s => s.id); - await enableRulesAction(deactivatedIds, true, dispatch); + await enableRulesAction(deactivatedIds, true, dispatch, dispatchToaster); }} > {i18n.BATCH_ACTION_ACTIVATE_SELECTED} @@ -40,7 +43,7 @@ export const getBatchItems = ( onClick={async () => { closePopover(); const activatedIds = selectedState.filter(s => s.activate).map(s => s.id); - await enableRulesAction(activatedIds, false, dispatch); + await enableRulesAction(activatedIds, false, dispatch, dispatchToaster); }} > {i18n.BATCH_ACTION_DEACTIVATE_SELECTED} @@ -72,12 +75,14 @@ export const getBatchItems = ( { closePopover(); await deleteRulesAction( selectedState.map(({ sourceRule: { id } }) => id), - dispatch + dispatch, + dispatchToaster ); }} > diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 95b9c9324894fb..0c1804f26ecdd8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -8,7 +8,6 @@ import { EuiBadge, - EuiHealth, EuiIconTip, EuiLink, EuiTextColor, @@ -16,8 +15,7 @@ import { EuiTableActionsColumnType, } from '@elastic/eui'; import * as H from 'history'; -import React from 'react'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React, { Dispatch } from 'react'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { deleteRulesAction, @@ -32,8 +30,14 @@ import { TableData } from '../types'; import * as i18n from '../translations'; import { PreferenceFormattedDate } from '../../../../components/formatted_date'; import { RuleSwitch } from '../components/rule_switch'; +import { SeverityBadge } from '../components/severity_badge'; +import { ActionToaster } from '../../../../components/toasters'; -const getActions = (dispatch: React.Dispatch, history: H.History) => [ +const getActions = ( + dispatch: React.Dispatch, + dispatchToaster: Dispatch, + history: H.History +) => [ { description: i18n.EDIT_RULE_SETTINGS, icon: 'visControls', @@ -52,7 +56,8 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ description: i18n.DUPLICATE_RULE, icon: 'copy', name: i18n.DUPLICATE_RULE, - onClick: (rowItem: TableData) => duplicateRuleAction(rowItem.sourceRule, dispatch), + onClick: (rowItem: TableData) => + duplicateRuleAction(rowItem.sourceRule, dispatch, dispatchToaster), }, { description: i18n.EXPORT_RULE, @@ -64,7 +69,8 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ description: i18n.DELETE_RULE, icon: 'trash', name: i18n.DELETE_RULE, - onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch), + onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch, dispatchToaster), + enabled: (rowItem: TableData) => !rowItem.immutable, }, ]; @@ -73,6 +79,7 @@ type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType, + dispatchToaster: Dispatch, history: H.History, hasNoPermissions: boolean ): RulesColumns[] => { @@ -92,21 +99,7 @@ export const getColumns = ( { field: 'severity', name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), + render: (value: TableData['severity']) => , truncateText: true, }, { @@ -179,7 +172,7 @@ export const getColumns = ( ]; const actions: RulesColumns[] = [ { - actions: getActions(dispatch, history), + actions: getActions(dispatch, dispatchToaster, history), width: '40px', } as EuiTableActionsColumnType, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx new file mode 100644 index 00000000000000..e925161444e424 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { bucketRulesResponse, formatRules } from './helpers'; +import { mockRule, mockRuleError, mockRules, mockTableData } from './__mocks__/mock'; +import uuid from 'uuid'; +import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; + +describe('AllRulesTable Helpers', () => { + const mockRule1: Readonly = mockRule(uuid.v4()); + const mockRule2: Readonly = mockRule(uuid.v4()); + const mockRuleError1: Readonly = mockRuleError(uuid.v4()); + const mockRuleError2: Readonly = mockRuleError(uuid.v4()); + + describe('formatRules', () => { + test('formats rules with no selection', () => { + const formattedRules = formatRules(mockRules); + expect(formattedRules).toEqual(mockTableData); + }); + + test('formats rules with selection', () => { + const mockTableDataWithSelected = [...mockTableData]; + mockTableDataWithSelected[0].isLoading = true; + const formattedRules = formatRules(mockRules, [mockRules[0].id]); + expect(formattedRules).toEqual(mockTableDataWithSelected); + }); + }); + + describe('bucketRulesResponse', () => { + test('buckets empty response', () => { + const bucketedResponse = bucketRulesResponse([]); + expect(bucketedResponse).toEqual({ rules: [], errors: [] }); + }); + + test('buckets all error response', () => { + const bucketedResponse = bucketRulesResponse([mockRuleError1, mockRuleError2]); + expect(bucketedResponse).toEqual({ rules: [], errors: [mockRuleError1, mockRuleError2] }); + }); + + test('buckets all success response', () => { + const bucketedResponse = bucketRulesResponse([mockRule1, mockRule2]); + expect(bucketedResponse).toEqual({ rules: [mockRule1, mockRule2], errors: [] }); + }); + + test('buckets mixed success/error response', () => { + const bucketedResponse = bucketRulesResponse([ + mockRule1, + mockRuleError1, + mockRule2, + mockRuleError2, + ]); + expect(bucketedResponse).toEqual({ + rules: [mockRule1, mockRule2], + errors: [mockRuleError1, mockRuleError2], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index f5d39553142429..b18938920082d7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -4,13 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Rule } from '../../../../containers/detection_engine/rules'; +import { + Rule, + RuleError, + RuleResponseBuckets, +} from '../../../../containers/detection_engine/rules'; import { TableData } from '../types'; import { getEmptyValue } from '../../../../components/empty_value'; +/** + * Formats rules into the correct format for the AllRulesTable + * + * @param rules as returned from the Rules API + * @param selectedIds ids of the currently selected rules + */ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] => rules.map(rule => ({ id: rule.id, + immutable: rule.immutable, rule_id: rule.rule_id, rule: { href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`, @@ -28,3 +39,18 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] sourceRule: rule, isLoading: selectedIds?.includes(rule.id) ?? false, })); + +/** + * Separates rules/errors from bulk rules API response (create/update/delete) + * + * @param response Array from bulk rules API + */ +export const bucketRulesResponse = (response: Array) => + response.reduce( + (acc, cv): RuleResponseBuckets => { + return 'error' in cv + ? { rules: [...acc.rules], errors: [...acc.errors, cv] } + : { rules: [...acc.rules, cv], errors: [...acc.errors] }; + }, + { rules: [], errors: [] } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index e900058b6c53cf..202be75f09e699 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -84,11 +84,35 @@ export const AllRules = React.memo<{ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( - + ), - [selectedItems, dispatch] + [selectedItems, dispatch, dispatchToaster] + ); + + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + dispatch({ + type: 'updatePagination', + pagination: { ...pagination, page: page.index + 1, perPage: page.size }, + }); + dispatch({ + type: 'updateFilterOptions', + filterOptions: { + ...filterOptions, + sortField: 'enabled', // Only enabled is supported for sorting currently + sortOrder: sort?.direction ?? 'desc', + }, + }); + }, + [dispatch, filterOptions, pagination] ); + const columns = useMemo(() => { + return getColumns(dispatch, dispatchToaster, history, hasNoPermissions); + }, [dispatch, dispatchToaster, history]); + useEffect(() => { dispatch({ type: 'loading', isLoading: isLoadingRules }); @@ -195,29 +219,16 @@ export const AllRules = React.memo<{ { - dispatch({ - type: 'updatePagination', - pagination: { ...pagination, page: page.index + 1, perPage: page.size }, - }); - dispatch({ - type: 'updateFilterOptions', - filterOptions: { - ...filterOptions, - sortField: 'enabled', // Only enabled is supported for sorting currently - sortOrder: sort!.direction, - }, - }); - }} + onChange={tableOnChangeCallback} pagination={{ pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20], + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], }} sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }} selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index b3cc81b5cdfcfc..0c75da7d8a6320 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -37,6 +37,25 @@ const MyEuiFormRow = styled(EuiFormRow)` } `; +export const MyAddItemButton = styled(EuiButtonEmpty)` + margin-top: 4px; + + &.euiButtonEmpty--xSmall { + font-size: 12px; + } + + .euiIcon { + width: 12px; + height: 12px; + } +`; + +MyAddItemButton.defaultProps = { + flush: 'left', + iconType: 'plusInCircle', + size: 'xs', +}; + export const AddItem = ({ addText, dataTestSubj, @@ -160,9 +179,9 @@ export const AddItem = ({ ); })} - + {addText} - + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg new file mode 100644 index 00000000000000..527d8d445bc03e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx index 09d0c1131ea10d..e8b6919165c8bf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -9,12 +9,10 @@ import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, - EuiHealth, EuiLink, - EuiText, - EuiListGroup, + EuiButtonEmpty, + EuiSpacer, } from '@elastic/eui'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { isEmpty } from 'lodash/fp'; import React from 'react'; @@ -27,6 +25,11 @@ import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_ import { FilterLabel } from './filter_label'; import * as i18n from './translations'; import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types'; +import { SeverityBadge } from '../severity_badge'; +import ListTreeIcon from './assets/list_tree_icon.svg'; + +const isNotEmptyArray = (values: string[]) => + !isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0; const EuiBadgeWrap = styled(EuiBadge)` .euiBadge__text { @@ -97,10 +100,17 @@ const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` } `; -const MyEuiListGroup = styled(EuiListGroup)` - padding: 0px; - .euiListGroupItem__button { - padding: 0px; +const TechniqueLinkItem = styled(EuiButtonEmpty)` + .euiIcon { + width: 8px; + height: 8px; + } +`; + +const ReferenceLinkItem = styled(EuiButtonEmpty)` + .euiIcon { + width: 12px; + height: 12px; } `; @@ -118,28 +128,31 @@ export const buildThreatsDescription = ({ const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); return ( - -
- - {tactic != null ? tactic.text : ''} - -
- { - const myTechnique = techniquesOptions.find(t => t.name === technique.name); - return { - label: myTechnique != null ? myTechnique.label : '', - href: technique.reference, - target: '_blank', - }; - })} - /> -
+ + {tactic != null ? tactic.text : ''} + + + {threat.techniques.map(technique => { + const myTechnique = techniquesOptions.find(t => t.name === technique.name); + return ( + + + {myTechnique != null ? myTechnique.label : ''} + + + ); + })} +
); })} + ), }, @@ -148,12 +161,34 @@ export const buildThreatsDescription = ({ return []; }; +export const buildUnorderedListArrayDescription = ( + label: string, + field: string, + values: string[] +): ListItems[] => { + if (isNotEmptyArray(values)) { + return [ + { + title: label, + description: ( +
    + {values.map((val: string) => + isEmpty(val) ? null :
  • {val}
  • + )} +
+ ), + }, + ]; + } + return []; +}; + export const buildStringArrayDescription = ( label: string, field: string, values: string[] ): ListItems[] => { - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + if (isNotEmptyArray(values)) { return [ { title: label, @@ -174,46 +209,34 @@ export const buildStringArrayDescription = ( return []; }; -export const buildSeverityDescription = (label: string, value: string): ListItems[] => { - return [ - { - title: label, - description: ( - - {value} - - ), - }, - ]; -}; +export const buildSeverityDescription = (label: string, value: string): ListItems[] => [ + { + title: label, + description: , + }, +]; export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => { - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + if (isNotEmptyArray(values)) { return [ { title: label, description: ( - ({ - label: val, - href: val, - iconType: 'link', - size: 'xs', - target: '_blank', - }))} - /> + + {values.map((val: string) => ( + + + {val} + + + ))} + ), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index af4f93c0fdbcd2..8cf1601e2c4b64 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty, chunk, get, pick } from 'lodash/fp'; import React, { memo, useState } from 'react'; -import styled from 'styled-components'; import { IIndexPattern, @@ -26,6 +25,7 @@ import { buildSeverityDescription, buildStringArrayDescription, buildThreatsDescription, + buildUnorderedListArrayDescription, buildUrlsDescription, } from './helpers'; @@ -36,15 +36,6 @@ interface StepRuleDescriptionProps { schema: FormSchema; } -const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>` - ${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')}; -`; - -const MyEuiTextArea = styled(EuiTextArea)` - max-width: 100%; - height: 80px; -`; - const StepRuleDescriptionComponent: React.FC = ({ data, direction = 'row', @@ -62,13 +53,24 @@ const StepRuleDescriptionComponent: React.FC = ({ ], [] ); + + if (direction === 'row') { + return ( + + {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( + + + + ))} + + ); + } + return ( - - {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( - - - - ))} + + + + ); }; @@ -123,18 +125,28 @@ const getDescriptionItem = ( return [ { title: label, - description: , + description: get(field, value), }, ]; } else if (field === 'references') { const urls: string[] = get(field, value); return buildUrlsDescription(label, urls); + } else if (field === 'falsePositives') { + const values: string[] = get(field, value); + return buildUnorderedListArrayDescription(label, field, values); } else if (Array.isArray(get(field, value))) { const values: string[] = get(field, value); return buildStringArrayDescription(label, field, values); } else if (field === 'severity') { const val: string = get(field, value); return buildSeverityDescription(label, val); + } else if (field === 'riskScore') { + return [ + { + title: label, + description: get(field, value), + }, + ]; } else if (field === 'timeline') { const timeline = get(field, value) as FieldValueTimeline; return [ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index 2c19e99e901140..f9a22c37cfdf00 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -5,7 +5,6 @@ */ import { - EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiSuperSelect, @@ -24,6 +23,7 @@ import * as Rulei18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; import { threatsDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; +import { MyAddItemButton } from '../add_item_form'; import { isMitreAttackInvalid } from './helpers'; import * as i18n from './translations'; @@ -134,13 +134,19 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => { const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques); + const options = techniquesOptions.filter(t => t.tactics.includes(kebabCase(item.tactic.name))); + const selectedOptions = item.techniques.map(technic => ({ + ...technic, + label: `${technic.name} (${technic.id})`, // API doesn't allow for label field + })); + return ( t.tactics.includes(kebabCase(item.tactic.name)))} - selectedOptions={item.techniques} + options={options} + selectedOptions={selectedOptions} onChange={updateTechniques.bind(null, index)} isDisabled={disabled || item.tactic.name === 'none'} fullWidth={true} @@ -202,9 +208,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI {values.length - 1 !== index && } ))} - + {i18n.ADD_MITRE_ATTACK} - + ); }; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/index.js b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx similarity index 50% rename from x-pack/legacy/plugins/ml/public/application/components/controls/index.js rename to x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx index 26cb89d672632d..0dab87b0a3b744 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/index.js +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx @@ -4,6 +4,13 @@ * 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'; +import { EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as RuleI18n from '../../translations'; + +export const OptionalFieldLabel = ( + + {RuleI18n.OPTIONAL_FIELD} + +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index 3e39beb6e61b78..46a7a13ec03f16 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -51,7 +51,7 @@ interface QueryBarDefineRuleProps { const StyledEuiFormRow = styled(EuiFormRow)` .kbnTypeahead__items { - max-height: 14vh !important; + max-height: 45vh !important; } .globalQueryBar { padding: 4px 0px 0px 0px; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx index 09be3df7d6929c..9cb0323ed8987a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx @@ -18,6 +18,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { enableRules } from '../../../../../containers/detection_engine/rules'; import { enableRulesAction } from '../../all/actions'; import { Action } from '../../all/reducer'; +import { useStateToaster } from '../../../../../components/toasters'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -50,12 +51,13 @@ export const RuleSwitchComponent = ({ }: RuleSwitchProps) => { const [myIsLoading, setMyIsLoading] = useState(false); const [myEnabled, setMyEnabled] = useState(enabled ?? false); + const [, dispatchToaster] = useStateToaster(); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { setMyIsLoading(true); if (dispatch != null) { - await enableRulesAction([id], event.target.checked!, dispatch); + await enableRulesAction([id], event.target.checked!, dispatch, dispatchToaster); } else { try { const updatedRules = await enableRules({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 8097c27cddfe8b..fa4bea319f8590 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldNumber, + EuiFormRow, + EuiSelect, + EuiFormControlLayout, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; @@ -26,10 +33,28 @@ const timeTypeOptions = [ { value: 'h', text: I18n.HOURS }, ]; +// move optional label to the end of input +const StyledLabelAppend = styled(EuiFlexItem)` + &.euiFlexItem.euiFlexItem--flexGrowZero { + margin-left: 31px; + } +`; + const StyledEuiFormRow = styled(EuiFormRow)` + max-width: none; + .euiFormControlLayout { max-width: 200px !important; } + + .euiFormControlLayout__childrenWrapper > *:first-child { + box-shadow: none; + height: 38px; + } + + .euiFormControlLayout:not(:first-child) { + border-left: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + } `; const MyEuiSelect = styled(EuiSelect)` @@ -89,9 +114,9 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu {field.label} - + {field.labelAppend} - + ), [field.label, field.labelAppend] @@ -107,7 +132,7 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu data-test-subj={dataTestSubj} describedByIds={idAria ? [idAria] : undefined} > - } - fullWidth - min={0} - onChange={onChangeTimeVal} - value={timeVal} - {...rest} - /> + > + + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx new file mode 100644 index 00000000000000..09c02dfca56f96 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { upperFirst } from 'lodash/fp'; +import React from 'react'; +import { EuiHealth } from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +interface Props { + value: string; +} + +const SeverityBadgeComponent: React.FC = ({ value }) => ( + + {upperFirst(value)} + +); + +export const SeverityBadge = React.memo(SeverityBadgeComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx index 9fb64189ebd1af..269d2d4509508d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import styled from 'styled-components'; import { EuiHealth } from '@elastic/eui'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; @@ -16,22 +17,30 @@ interface SeverityOptionItem { inputDisplay: React.ReactElement; } +const StyledEuiHealth = styled(EuiHealth)` + line-height: inherit; +`; + export const severityOptions: SeverityOptionItem[] = [ { value: 'low', - inputDisplay: {I18n.LOW}, + inputDisplay: {I18n.LOW}, }, { value: 'medium', - inputDisplay: {I18n.MEDIUM} , + inputDisplay: ( + {I18n.MEDIUM} + ), }, { value: 'high', - inputDisplay: {I18n.HIGH} , + inputDisplay: {I18n.HIGH}, }, { value: 'critical', - inputDisplay: {I18n.CRITICAL} , + inputDisplay: ( + {I18n.CRITICAL} + ), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 8956776dcd3b21..0e03a11776fb7e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; @@ -22,6 +22,7 @@ import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; import { PickTimeline } from '../pick_timeline'; +import { StepContentWrapper } from '../step_content_wrapper'; const CommonUseField = getUseField({ component: Field }); @@ -33,64 +34,67 @@ const TagContainer = styled.div` margin-top: 16px; `; -export const StepAboutRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isUpdateView = false, - isLoading, - setForm, - setStepData, - }) => { - const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); +const StepAboutRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isUpdateView = false, + isLoading, + setForm, + setStepData, +}) => { + const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await form.submit(); - if (isValid) { - setStepData(RuleStep.aboutRule, data, isValid); - setMyStepData({ ...data, isNew: false } as AboutStepRule); - } + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.aboutRule, null, false); + const { isValid, data } = await form.submit(); + if (isValid) { + setStepData(RuleStep.aboutRule, data, isValid); + setMyStepData({ ...data, isNew: false } as AboutStepRule); } - }, [form]); + } + }, [form]); - useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); - } + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } - }, [defaultValues]); + } + }, [defaultValues]); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.aboutRule, form); - } - }, [form]); + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.aboutRule, form); + } + }, [form]); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
( }} - {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - - )} - - ); - } -); +
+ {!isUpdateView && ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + )} + + ); +}; + +export const StepAboutRule = memo(StepAboutRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 008a1b48610d69..3de0e7605f3d97 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import * as RuleI18n from '../../translations'; import { IMitreEnterpriseAttack } from '../../types'; import { FIELD_TYPES, @@ -18,6 +15,7 @@ import { ERROR_CODE, } from '../shared_imports'; import { isMitreAttackInvalid } from '../mitre/helpers'; +import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from './helpers'; import * as I18n from './translations'; @@ -108,7 +106,7 @@ export const schema: FormSchema = { defaultMessage: 'Reference URLs', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, validations: [ { validator: ( @@ -136,10 +134,10 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives examples', + defaultMessage: 'False positive examples', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, }, threats: { label: i18n.translate( @@ -148,7 +146,7 @@ export const schema: FormSchema = { defaultMessage: 'MITRE ATT&CK\\u2122', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, validations: [ { validator: ( @@ -184,6 +182,6 @@ export const schema: FormSchema = { 'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx new file mode 100644 index 00000000000000..b04a321dab05bc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.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 React from 'react'; +import styled from 'styled-components'; + +const StyledDiv = styled.div<{ addPadding: boolean }>` + padding-left: ${({ addPadding }) => addPadding && '53px'}; /* to align with the step title */ +`; + +StyledDiv.defaultProps = { + addPadding: false, +}; + +export const StepContentWrapper = React.memo(StyledDiv); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index ecd2ce442238fa..6bdef4a69af1e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -12,7 +12,8 @@ import { EuiButton, } from '@elastic/eui'; import { isEmpty, isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import styled from 'styled-components'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; @@ -22,6 +23,7 @@ import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; +import { StepContentWrapper } from '../step_content_wrapper'; import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; @@ -42,6 +44,20 @@ const stepDefineDefaultValue = { }, }; +const MyLabelButton = styled(EuiButtonEmpty)` + height: 18px; + font-size: 12px; + + .euiIcon { + width: 14px; + height: 14px; + } +`; + +MyLabelButton.defaultProps = { + flush: 'right', +}; + const getStepDefaultValue = ( indicesConfig: string[], defaultValues: DefineStepRule | null @@ -59,106 +75,104 @@ const getStepDefaultValue = ( } }; -export const StepDefineRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isLoading, - isUpdateView = false, - resizeParentContainer, - setForm, - setStepData, - }) => { - const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); - const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); - const [ - { - browserFields, - indexPatterns: indexPatternQueryBar, - isLoading: indexPatternLoadingQueryBar, - }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); - - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); - - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await form.submit(); - if (isValid && setStepData) { - setStepData(RuleStep.defineRule, data, isValid); - setMyStepData({ ...data, isNew: false } as DefineStepRule); - } +const StepDefineRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isLoading, + isUpdateView = false, + setForm, + setStepData, +}) => { + const [openTimelineSearch, setOpenTimelineSearch] = useState(false); + const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); + const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( + defaultValues != null ? defaultValues.index : indicesConfig ?? [] + ); + const [ + { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, + ] = useFetchIndexPatterns(mylocalIndicesConfig); + const [myStepData, setMyStepData] = useState( + getStepDefaultValue(indicesConfig, null) + ); + + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.defineRule, null, false); + const { isValid, data } = await form.submit(); + if (isValid && setStepData) { + setStepData(RuleStep.defineRule, data, isValid); + setMyStepData({ ...data, isNew: false } as DefineStepRule); } - }, [form]); - - useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!isEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); - } + } + }, [form]); + + useEffect(() => { + if (indicesConfig != null && defaultValues != null) { + const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); + if (!isEqual(myDefaultValues, myStepData)) { + setMyStepData(myDefaultValues); + setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } } - }, [defaultValues, indicesConfig]); + } + }, [defaultValues, indicesConfig]); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.defineRule, form); - } - }, [form]); + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.defineRule, form); + } + }, [form]); - const handleResetIndices = useCallback(() => { - const indexField = form.getFields().index; - indexField.setValue(indicesConfig); - }, [form, indicesConfig]); + const handleResetIndices = useCallback(() => { + const indexField = form.getFields().index; + indexField.setValue(indicesConfig); + }, [form, indicesConfig]); - const handleOpenTimelineSearch = useCallback(() => { - setOpenTimelineSearch(true); - }, []); + const handleOpenTimelineSearch = useCallback(() => { + setOpenTimelineSearch(true); + }, []); - const handleCloseTimelineSearch = useCallback(() => { - setOpenTimelineSearch(false); - }, []); + const handleCloseTimelineSearch = useCallback(() => { + setOpenTimelineSearch(false); + }, []); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
- {i18n.RESET_DEFAULT_INDEX} - + + {i18n.RESET_DEFAULT_INDEX} + ) : null, }} componentProps={{ @@ -176,9 +190,9 @@ export const StepDefineRule = memo( config={{ ...schema.queryBar, labelAppend: ( - - {i18n.IMPORT_TIMELINE_QUERY} - + + {i18n.IMPORT_TIMELINE_QUERY} + ), }} component={QueryBarDefineRule} @@ -192,7 +206,6 @@ export const StepDefineRule = memo( dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', openTimelineSearch, onCloseTimelineSearch: handleCloseTimelineSearch, - resizeParentContainer, }} /> @@ -212,24 +225,26 @@ export const StepDefineRule = memo( }} - {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - - )} - - ); - } -); +
+ {!isUpdateView && ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + )} + + ); +}; + +export const StepDefineRule = memo(StepDefineRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 35b8ca6650bf6d..b99201abe87777 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -6,12 +6,13 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import { RuleStep, RuleStepProps, ScheduleStepRule } from '../../types'; import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../shared_imports'; +import { StepContentWrapper } from '../step_content_wrapper'; import { schema } from './schema'; import * as I18n from './translations'; @@ -26,67 +27,70 @@ const stepScheduleDefaultValue = { from: '0m', }; -export const StepScheduleRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isLoading, - isUpdateView = false, - setStepData, - setForm, - }) => { - const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); +const StepScheduleRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isLoading, + isUpdateView = false, + setStepData, + setForm, +}) => { + const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } - } - }, - [form] - ); - - useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); + const onSubmit = useCallback( + async (enabled: boolean) => { + if (setStepData) { + setStepData(RuleStep.scheduleRule, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); + setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } } - }, [defaultValues]); + }, + [form] + ); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.scheduleRule, form); + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } - }, [form]); + } + }, [defaultValues]); - return isReadOnlyView && myStepData != null ? ( + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.scheduleRule, form); + } + }, [form]); + + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
( }} /> +
+ + {!isUpdateView && ( + <> + + + + + {I18n.COMPLETE_WITHOUT_ACTIVATING} + + + + + {I18n.COMPLETE_WITH_ACTIVATING} + + + + + )} + + ); +}; - {!isUpdateView && ( - <> - - - - - {I18n.COMPLETE_WITHOUT_ACTIVATING} - - - - - {I18n.COMPLETE_WITH_ACTIVATING} - - - - - )} - - ); - } -); +export const StepScheduleRule = memo(StepScheduleRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index 31e56265dec424..4da17b88b9ad0f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; -import React from 'react'; import { i18n } from '@kbn/i18n'; -import * as RuleI18n from '../../translations'; +import { OptionalFieldLabel } from '../optional_field_label'; import { FormSchema } from '../shared_imports'; export const schema: FormSchema = { @@ -33,7 +31,7 @@ export const schema: FormSchema = { defaultMessage: 'Additional look-back', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, helpText: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepScheduleRule.fieldAdditionalLookBackHelpText', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 9a0f41bbd8c517..e5656f5b081fb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -27,26 +27,17 @@ import * as i18n from './translations'; const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule]; -const ResizeEuiPanel = styled(EuiPanel)<{ - height?: number; +const MyEuiPanel = styled(EuiPanel)<{ + zIndex?: number; }>` + position: relative; + z-index: ${props => props.zIndex}; /* ugly fix to allow searchBar to overflow the EuiPanel */ + .euiAccordion__iconWrapper { display: none; } .euiAccordion__childWrapper { - height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')}; - } - .euiAccordion__button { - cursor: default !important; - &:hover { - text-decoration: none !important; - } - } -`; - -const MyEuiPanel = styled(EuiPanel)` - .euiAccordion__iconWrapper { - display: none; + overflow: visible; } .euiAccordion__button { cursor: default !important; @@ -64,7 +55,6 @@ export const CreateRuleComponent = React.memo(() => { canUserCRUD, hasManageApiKey, } = useUserInfo(); - const [heightAccordion, setHeightAccordion] = useState(-1); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); @@ -239,7 +229,7 @@ export const CreateRuleComponent = React.memo(() => { isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { ) } > - + setHeightAccordion(height)} + descriptionDirection="row" /> - - - + + + { ) } > - + { /> - - + + { ) } > - + ( {aboutRuleData != null && ( + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error activating {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle', { @@ -57,6 +66,15 @@ export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_DEACTIVATE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error deactivating {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const BATCH_ACTION_EXPORT_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.exportSelectedTitle', { @@ -78,6 +96,22 @@ export const BATCH_ACTION_DELETE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle', + { + defaultMessage: 'Selection contains immutable rules which cannot be deleted', + } +); + +export const BATCH_ACTION_DELETE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error deleting {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const EXPORT_FILENAME = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.exportFilenameTitle', { @@ -143,6 +177,13 @@ export const DUPLICATE_RULE = i18n.translate( } ); +export const DUPLICATE_RULE_ERROR = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription', + { + defaultMessage: 'Error duplicating rule…', + } +); + export const EXPORT_RULE = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.actions.exportRuleDescription', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 541b058951be7f..3da294fc9b8454 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -25,6 +25,7 @@ export interface EuiBasicTableOnChange { export interface TableData { id: string; + immutable: boolean; rule_id: string; rule: { href: string; @@ -57,6 +58,7 @@ export interface RuleStepData { } export interface RuleStepProps { + addPadding?: boolean; descriptionDirection?: 'row' | 'column'; setStepData?: (step: RuleStep, data: unknown, isValid: boolean) => void; isReadOnlyView: boolean; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index ab2c1733b04ca2..774afb6d7deb07 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -6,6 +6,7 @@ import { schema } from '@kbn/config-schema'; import { Logger } from 'src/core/server'; +import moment from 'moment'; import { SIGNALS_ID, DEFAULT_MAX_SIGNALS, @@ -17,6 +18,7 @@ import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition } from './types'; +import { getGapBetweenRuns } from './utils'; export const signalRulesAlertType = ({ logger, @@ -57,7 +59,8 @@ export const signalRulesAlertType = ({ version: schema.number({ defaultValue: 1 }), }), }, - async executor({ alertId, services, params }) { + // fun fact: previousStartedAt is not actually a Date but a String of a date + async executor({ previousStartedAt, alertId, services, params }) { const { from, ruleId, @@ -70,7 +73,6 @@ export const signalRulesAlertType = ({ to, type, } = params; - // TODO: Remove this hard extraction of name once this is fixed: https://github.com/elastic/kibana/issues/50522 const savedObject = await services.savedObjectsClient.get('alert', alertId); const name: string = savedObject.attributes.name; @@ -78,9 +80,19 @@ export const signalRulesAlertType = ({ const createdBy: string = savedObject.attributes.createdBy; const updatedBy: string = savedObject.attributes.updatedBy; - const interval: string = savedObject.attributes.interval; + const interval: string = savedObject.attributes.schedule.interval; const enabled: boolean = savedObject.attributes.enabled; - + const gap = getGapBetweenRuns({ + previousStartedAt: previousStartedAt != null ? moment(previousStartedAt) : null, // TODO: Remove this once previousStartedAt is no longer a string + interval, + from, + to, + }); + if (gap != null && gap.asMilliseconds() > 0) { + logger.warn( + `Signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" has a time gap of ${gap.humanize()} (${gap.asMilliseconds()}ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.` + ); + } // set searchAfter page size to be the lesser of default page size or maxSignals. const searchAfterSize = DEFAULT_SEARCH_AFTER_PAGE_SIZE <= params.maxSignals @@ -155,7 +167,7 @@ export const signalRulesAlertType = ({ // TODO: Error handling and writing of errors into a signal that has error // handling/conditions logger.error( - `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${err.message}` ); } }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts new file mode 100644 index 00000000000000..d6a3da5a393f8c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts @@ -0,0 +1,258 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; + +import { generateId, parseInterval, getDriftTolerance, getGapBetweenRuns } from './utils'; + +describe('utils', () => { + let nowDate = moment('2020-01-01T00:00:00.000Z'); + + beforeEach(() => { + nowDate = moment('2020-01-01T00:00:00.000Z'); + }); + + describe('generateId', () => { + test('it generates expected output', () => { + const id = generateId('index-123', 'doc-123', 'version-123', 'rule-123'); + expect(id).toEqual('10622e7d06c9e38a532e71fc90e3426c1100001fb617aec8cb974075da52db06'); + }); + + test('expected output is a hex', () => { + const id = generateId('index-123', 'doc-123', 'version-123', 'rule-123'); + expect(id).toMatch(/[a-f0-9]+/); + }); + }); + + describe('getIntervalMilliseconds', () => { + test('it returns a duration when given one that is valid', () => { + const duration = parseInterval('5m'); + expect(duration).not.toBeNull(); + expect(duration?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); + }); + + test('it returns null given an invalid duration', () => { + const duration = parseInterval('junk'); + expect(duration).toBeNull(); + }); + }); + + describe('getDriftToleranceMilliseconds', () => { + test('it returns a drift tolerance in milliseconds of 1 minute when from overlaps to by 1 minute and the interval is 5 minutes', () => { + const drift = getDriftTolerance({ + from: 'now-6m', + to: 'now', + interval: moment.duration(5, 'minutes'), + }); + expect(drift).not.toBeNull(); + expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); + }); + + test('it returns a drift tolerance of 0 when from equals the interval', () => { + const drift = getDriftTolerance({ + from: 'now-5m', + to: 'now', + interval: moment.duration(5, 'minutes'), + }); + expect(drift?.asMilliseconds()).toEqual(0); + }); + + test('it returns a drift tolerance of 5 minutes when from is 10 minutes but the interval is 5 minutes', () => { + const drift = getDriftTolerance({ + from: 'now-10m', + to: 'now', + interval: moment.duration(5, 'minutes'), + }); + expect(drift).not.toBeNull(); + expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); + }); + + test('it returns a drift tolerance of 10 minutes when from is 10 minutes ago and the interval is 0', () => { + const drift = getDriftTolerance({ + from: 'now-10m', + to: 'now', + interval: moment.duration(0, 'milliseconds'), + }); + expect(drift).not.toBeNull(); + expect(drift?.asMilliseconds()).toEqual(moment.duration(10, 'minutes').asMilliseconds()); + }); + + test('returns null if the "to" is not "now" since we have limited support for date math', () => { + const drift = getDriftTolerance({ + from: 'now-6m', + to: 'invalid', // if not set to "now" this function returns null + interval: moment.duration(1000, 'milliseconds'), + }); + expect(drift).toBeNull(); + }); + + test('returns null if the "from" does not start with "now-" since we have limited support for date math', () => { + const drift = getDriftTolerance({ + from: 'valid', // if not set to "now-x" where x is an interval such as 6m + to: 'now', + interval: moment.duration(1000, 'milliseconds'), + }); + expect(drift).toBeNull(); + }); + + test('returns null if the "from" starts with "now-" but has a string instead of an integer', () => { + const drift = getDriftTolerance({ + from: 'now-dfdf', // if not set to "now-x" where x is an interval such as 6m + to: 'now', + interval: moment.duration(1000, 'milliseconds'), + }); + expect(drift).toBeNull(); + }); + }); + + describe('getGapBetweenRuns', () => { + test('it returns a gap of 0 when from and interval match each other and the previous started was from the previous interval time', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(5, 'minutes'), + interval: '5m', + from: 'now-5m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(0); + }); + + test('it returns a negative gap of 1 minute when from overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(5, 'minutes'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-1, 'minute').asMilliseconds()); + }); + + test('it returns a negative gap of 5 minutes when from overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(5, 'minutes'), + interval: '5m', + from: 'now-10m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-5, 'minute').asMilliseconds()); + }); + + test('it returns a negative gap of 1 minute when from overlaps to by 1 minute and the previousStartedAt was 10 minutes ago and so was the interval', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(10, 'minutes'), + interval: '10m', + from: 'now-11m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-1, 'minute').asMilliseconds()); + }); + + test('it returns a gap of only -30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is 30 seconds more', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate + .clone() + .subtract(5, 'minutes') + .subtract(30, 'seconds'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-30, 'seconds').asMilliseconds()); + }); + + test('it returns an exact 0 gap when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute late', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(6, 'minutes'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(0, 'minute').asMilliseconds()); + }); + + test('it returns a gap of 30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute and 30 seconds late', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate + .clone() + .subtract(6, 'minutes') + .subtract(30, 'seconds'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(30, 'seconds').asMilliseconds()); + }); + + test('it returns a gap of 1 minute when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is two minutes late', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(7, 'minutes'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap?.asMilliseconds()).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); + }); + + test('it returns null if given a previousStartedAt of null', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: null, + interval: '5m', + from: 'now-5m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + + test('it returns null if the interval is an invalid string such as "invalid"', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone(), + interval: 'invalid', // if not set to "x" where x is an interval such as 6m + from: 'now-5m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + + test('it returns null if from is an invalid string such as "invalid"', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone(), + interval: '5m', + from: 'invalid', // if not set to "now-x" where x is an interval such as 6m + to: 'now', + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + + test('it returns null if to is an invalid string such as "invalid"', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone(), + interval: '5m', + from: 'now-5m', + to: 'invalid', // if not set to "now" this function returns null + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts index f25ce1d9054666..5a4c67ebaaa362 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { createHash } from 'crypto'; +import moment from 'moment'; + +import { parseDuration } from '../../../../../alerting/server/lib'; export const generateId = ( docIndex: string, @@ -14,3 +17,66 @@ export const generateId = ( createHash('sha256') .update(docIndex.concat(docId, version, ruleId)) .digest('hex'); + +export const parseInterval = (intervalString: string): moment.Duration | null => { + try { + return moment.duration(parseDuration(intervalString)); + } catch (err) { + return null; + } +}; + +export const getDriftTolerance = ({ + from, + to, + interval, +}: { + from: string; + to: string; + interval: moment.Duration; +}): moment.Duration | null => { + if (to.trim() !== 'now') { + // we only support 'now' for drift detection + return null; + } + if (!from.trim().startsWith('now-')) { + // we only support from tha starts with now for drift detection + return null; + } + const split = from.split('-'); + const duration = parseInterval(split[1]); + if (duration !== null) { + return duration.subtract(interval); + } else { + return null; + } +}; + +export const getGapBetweenRuns = ({ + previousStartedAt, + interval, + from, + to, + now = moment(), +}: { + previousStartedAt: moment.Moment | undefined | null; + interval: string; + from: string; + to: string; + now?: moment.Moment; +}): moment.Duration | null => { + if (previousStartedAt == null) { + return null; + } + const intervalDuration = parseInterval(interval); + if (intervalDuration == null) { + return null; + } + const driftTolerance = getDriftTolerance({ from, to, interval: intervalDuration }); + if (driftTolerance == null) { + return null; + } + const diff = moment.duration(now.diff(previousStartedAt)); + const drift = diff.subtract(intervalDuration); + return drift.subtract(driftTolerance); +}; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx index 2206d6de341c84..111b46d596e565 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx @@ -347,7 +347,7 @@ export const PolicyStepLogistics: React.FunctionComponent = ({ onChange={e => { updatePolicy( { - snapshotName: e.target.value.toLowerCase(), + snapshotName: e.target.value, }, { managedRepository, diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts index 7d44979e697a7f..0720994ca76693 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts @@ -15,6 +15,16 @@ const isStringEmpty = (str: string | null): boolean => { return str ? !Boolean(str.trim()) : true; }; +// strExcludeDate is the concat results of the SnapshotName ...{...}>... without the date +// This way we can check only the SnapshotName portion for lowercasing +// For example: would give strExcludeDate = + +const isSnapshotNameNotLowerCase = (str: string): boolean => { + const strExcludeDate = + str.substring(0, str.search('{')) + str.substring(str.search('}>') + 1, str.length); + return strExcludeDate !== strExcludeDate.toLowerCase() ? true : false; +}; + export const validatePolicy = ( policy: SlmPolicyPayload, validationHelperData: { @@ -61,6 +71,14 @@ export const validatePolicy = ( ); } + if (isSnapshotNameNotLowerCase(snapshotName)) { + validation.errors.snapshotName.push( + i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameLowerCaseErrorMessage', { + defaultMessage: 'Snapshot name needs to be lowercase.', + }) + ); + } + if (isStringEmpty(schedule)) { validation.errors.schedule.push( i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredErrorMessage', { diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap b/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap index 5cad4e794cfda6..45daa03e94c2e3 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap +++ b/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap @@ -5,7 +5,7 @@ exports[`NavControlPopover renders without crashing 1`] = ` anchorPosition="downRight" button={ diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap b/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap index 079dab701cc1dd..8e78f64ac59cb0 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap +++ b/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap @@ -4,6 +4,7 @@ exports[`SpacesDescription renders without crashing 1`] = ` diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx index aacf3845e0e0f3..157dcab3e0be16 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx +++ b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx @@ -13,6 +13,7 @@ describe('SpacesDescription', () => { expect( shallow( void; capabilities: Capabilities; } export const SpacesDescription: FC = (props: Props) => { const panelProps = { + id: props.id, className: 'spcDescription', title: 'Spaces', }; diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 96ce18896b426b..4d89f57d4ccf18 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -20,6 +20,7 @@ import { ManageSpacesButton } from './manage_spaces_button'; import { SpaceAvatar } from '../../space_avatar'; interface Props { + id: string; spaces: Space[]; isLoading: boolean; onSelectSpace: (space: Space) => void; @@ -48,6 +49,7 @@ class SpacesMenuUI extends Component { : this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); const panelProps = { + id: this.props.id, className: 'spcMenu', title: intl.formatMessage({ id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx b/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx index f291027e15232c..59c8052a644da2 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx +++ b/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx @@ -32,6 +32,8 @@ interface State { spaces: Space[]; } +const popoutContentId = 'headerSpacesMenuContent'; + export class NavControlPopover extends Component { private activeSpace$?: Subscription; @@ -71,6 +73,7 @@ export class NavControlPopover extends Component { if (!this.state.loading && this.state.spaces.length < 2) { element = ( @@ -78,6 +81,7 @@ export class NavControlPopover extends Component { } else { element = ( { private getButton = (linkIcon: JSX.Element, linkTitle: string) => { return ( ({ - info: (message: string) => server.log(['info', 'task_manager'], message), - debug: (message: string) => server.log(['debug', 'task_manager'], message), - warn: (message: string) => server.log(['warn', 'task_manager'], message), - error: (message: string) => server.log(['error', 'task_manager'], message), - }), - }, - }); - const schema = new SavedObjectsSchema(this.kbnServer.uiExports.savedObjectSchemas); - const serializer = new SavedObjectsSerializer(schema); - const setupContract = plugin.setup( - {}, - { - serializer, - config: server.config(), - elasticsearch: server.plugins.elasticsearch, - savedObjects: server.savedObjects, - } + /* + * We must expose the New Platform Task Manager Plugin via the legacy Api + * as removing it now would be a breaking change - we'll remove this in v8.0.0 + */ + server.expose( + createLegacyApi( + getTaskManagerSetup(server)! + .registerLegacyAPI({ + savedObjectSchemas, + }) + .then((taskManagerPlugin: TaskManager) => { + // we can't tell the Kibana Platform Task Manager plugin to + // to wait to `start` as that happens before legacy plugins + // instead we will start the internal Task Manager plugin when + // all legacy plugins have finished initializing + // Once all plugins are migrated to NP, this can be removed + this.kbnServer.afterPluginsInit(() => { + taskManagerPlugin.start(); + }); + return taskManagerPlugin; + }) + ) ); - this.kbnServer.afterPluginsInit(() => { - plugin.start(); - }); - server.expose(setupContract); }, uiExports: { mappings, migrations, - savedObjectSchemas: { - task: { - hidden: true, - isNamespaceAgnostic: true, - convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, - indexPattern(config: any) { - return config.get('xpack.task_manager.index'); - }, - }, - }, + savedObjectSchemas, }, }); } diff --git a/x-pack/legacy/plugins/task_manager/server/legacy.ts b/x-pack/legacy/plugins/task_manager/server/legacy.ts new file mode 100644 index 00000000000000..772309d67c334f --- /dev/null +++ b/x-pack/legacy/plugins/task_manager/server/legacy.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'src/legacy/server/kbn_server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; + +import { Middleware } from '../../../../plugins/task_manager/server/lib/middleware.js'; +import { + TaskDictionary, + TaskInstanceWithDeprecatedFields, + TaskInstanceWithId, + TaskDefinition, +} from '../../../../plugins/task_manager/server/task.js'; +import { FetchOpts } from '../../../../plugins/task_manager/server/task_store.js'; + +// Once all plugins are migrated to NP and we can remove Legacy TaskManager in version 8.0.0, +// this can be removed +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TaskManager } from '../../../../plugins/task_manager/server/task_manager'; + +export type LegacyTaskManagerApi = Pick< + TaskManagerSetupContract, + 'addMiddleware' | 'registerTaskDefinitions' +> & + TaskManagerStartContract; + +export function getTaskManagerSetup(server: Server): TaskManagerSetupContract | undefined { + return server?.newPlatform?.setup?.plugins?.taskManager as TaskManagerSetupContract; +} + +export function getTaskManagerStart(server: Server): TaskManagerStartContract | undefined { + return server?.newPlatform?.start?.plugins?.taskManager as TaskManagerStartContract; +} + +export function createLegacyApi(legacyTaskManager: Promise): LegacyTaskManagerApi { + return { + addMiddleware: (middleware: Middleware) => { + legacyTaskManager.then((tm: TaskManager) => tm.addMiddleware(middleware)); + }, + registerTaskDefinitions: (taskDefinitions: TaskDictionary) => { + legacyTaskManager.then((tm: TaskManager) => tm.registerTaskDefinitions(taskDefinitions)); + }, + fetch: (opts: FetchOpts) => legacyTaskManager.then((tm: TaskManager) => tm.fetch(opts)), + remove: (id: string) => legacyTaskManager.then((tm: TaskManager) => tm.remove(id)), + schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => + legacyTaskManager.then((tm: TaskManager) => tm.schedule(taskInstance, options)), + runNow: (taskId: string) => legacyTaskManager.then((tm: TaskManager) => tm.runNow(taskId)), + ensureScheduled: (taskInstance: TaskInstanceWithId, options?: any) => + legacyTaskManager.then((tm: TaskManager) => tm.ensureScheduled(taskInstance, options)), + }; +} diff --git a/x-pack/legacy/plugins/task_manager/server/plugin.test.ts b/x-pack/legacy/plugins/task_manager/server/plugin.test.ts deleted file mode 100644 index f7c5b35da50c2e..00000000000000 --- a/x-pack/legacy/plugins/task_manager/server/plugin.test.ts +++ /dev/null @@ -1,73 +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 { Plugin, LegacyDeps } from './plugin'; -import { mockLogger } from './test_utils'; -import { TaskManager } from './task_manager'; - -jest.mock('./task_manager'); - -describe('Task Manager Plugin', () => { - let plugin: Plugin; - const mockCoreSetup = {}; - const mockLegacyDeps: LegacyDeps = { - config: { - get: jest.fn(), - }, - serializer: {}, - elasticsearch: { - getCluster: jest.fn(), - }, - savedObjects: { - getSavedObjectsRepository: jest.fn(), - }, - }; - - beforeEach(() => { - jest.resetAllMocks(); - mockLegacyDeps.elasticsearch.getCluster.mockReturnValue({ callWithInternalUser: jest.fn() }); - plugin = new Plugin({ - logger: { - get: mockLogger, - }, - }); - }); - - describe('setup()', () => { - test('exposes proper contract', async () => { - const setupResult = plugin.setup(mockCoreSetup, mockLegacyDeps); - expect(setupResult).toMatchInlineSnapshot(` - Object { - "addMiddleware": [Function], - "ensureScheduled": [Function], - "fetch": [Function], - "registerTaskDefinitions": [Function], - "remove": [Function], - "runNow": [Function], - "schedule": [Function], - } - `); - }); - }); - - describe('start()', () => { - test('properly starts up the task manager', async () => { - plugin.setup(mockCoreSetup, mockLegacyDeps); - plugin.start(); - const taskManager = (TaskManager as any).mock.instances[0]; - expect(taskManager.start).toHaveBeenCalled(); - }); - }); - - describe('stop()', () => { - test('properly stops up the task manager', async () => { - plugin.setup(mockCoreSetup, mockLegacyDeps); - plugin.stop(); - const taskManager = (TaskManager as any).mock.instances[0]; - expect(taskManager.stop).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/task_manager/server/plugin.ts b/x-pack/legacy/plugins/task_manager/server/plugin.ts deleted file mode 100644 index 08382d1d825b6b..00000000000000 --- a/x-pack/legacy/plugins/task_manager/server/plugin.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Logger } from './types'; -import { TaskManager } from './task_manager'; - -export interface PluginSetupContract { - fetch: TaskManager['fetch']; - remove: TaskManager['remove']; - schedule: TaskManager['schedule']; - runNow: TaskManager['runNow']; - ensureScheduled: TaskManager['ensureScheduled']; - addMiddleware: TaskManager['addMiddleware']; - registerTaskDefinitions: TaskManager['registerTaskDefinitions']; -} - -export interface LegacyDeps { - config: any; - serializer: any; - elasticsearch: any; - savedObjects: any; -} - -interface PluginInitializerContext { - logger: { - get: () => Logger; - }; -} - -export class Plugin { - private logger: Logger; - private taskManager?: TaskManager; - - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); - } - - // TODO: Make asynchronous like new platform - public setup( - core: {}, - { config, serializer, elasticsearch, savedObjects }: LegacyDeps - ): PluginSetupContract { - const { callWithInternalUser } = elasticsearch.getCluster('admin'); - const savedObjectsRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser, [ - 'task', - ]); - - const taskManager = new TaskManager({ - config, - savedObjectsRepository, - serializer, - callWithInternalUser, - logger: this.logger, - }); - this.taskManager = taskManager; - - return { - fetch: (...args) => taskManager.fetch(...args), - remove: (...args) => taskManager.remove(...args), - schedule: (...args) => taskManager.schedule(...args), - runNow: (...args) => taskManager.runNow(...args), - ensureScheduled: (...args) => taskManager.ensureScheduled(...args), - addMiddleware: (...args) => taskManager.addMiddleware(...args), - registerTaskDefinitions: (...args) => taskManager.registerTaskDefinitions(...args), - }; - } - - public start() { - if (this.taskManager) { - this.taskManager.start(); - } - } - - public stop() { - if (this.taskManager) { - this.taskManager.stop(); - } - } -} diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts b/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts index 4837e75fd31604..a4b80d902d0984 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts +++ b/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts @@ -4,23 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TaskManager } from './types'; - -const createTaskManagerMock = () => { - const mocked: jest.Mocked = { - registerTaskDefinitions: jest.fn(), - addMiddleware: jest.fn(), - ensureScheduled: jest.fn(), - schedule: jest.fn(), - fetch: jest.fn(), - runNow: jest.fn(), - remove: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }; - return mocked; -}; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; +import { Subject } from 'rxjs'; export const taskManagerMock = { - create: createTaskManagerMock, + setup(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + config$: new Subject(), + registerLegacyAPI: jest.fn(), + ...overrides, + }; + return mocked; + }, + start(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + ensureScheduled: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + runNow: jest.fn(), + remove: jest.fn(), + ...overrides, + }; + return mocked; + }, }; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts index a40453b3671b79..ea3cfe677ca991 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts @@ -10,6 +10,7 @@ import { CheckGeoType, SummaryType } from '../common'; export const MonitorLocationType = t.partial({ summary: SummaryType, geo: CheckGeoType, + timestamp: t.string, }); // Typescript type for type checking diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap deleted file mode 100644 index d731a168225b7b..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MonitorStatusBar component renders 1`] = ` -Array [ -
, -
-
- SSL certificate expires in 2 months -
-
, -] -`; - -exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx deleted file mode 100644 index 03eb252aa8c097..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import moment from 'moment'; -import { renderWithIntl } from 'test_utils/enzyme_helpers'; -import { PingTls } from '../../../../common/graphql/types'; -import { MonitorSSLCertificate } from '../monitor_status_details/monitor_status_bar'; - -describe('MonitorStatusBar component', () => { - let monitorTls: PingTls; - - beforeEach(() => { - const dateInTwoMonths = moment() - .add(2, 'month') - .toString(); - - monitorTls = { - certificate_not_valid_after: dateInTwoMonths, - }; - }); - - it('renders', () => { - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('renders null if invalid date', () => { - monitorTls = { - certificate_not_valid_after: 'i am so invalid date', - }; - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap new file mode 100644 index 00000000000000..6228183e7c2b2b --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap @@ -0,0 +1,577 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StatusByLocation component renders when all locations are down 1`] = ` +.c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ +
+`; + +exports[`StatusByLocation component renders when all locations are up 1`] = ` +.c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ + +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+
+`; + +exports[`StatusByLocation component renders when there are many location 1`] = ` +Array [ + .c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ st-paul +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Tokya +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ New York +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Toronto +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Sydney +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Paris +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ +
, + .c0 { + padding-left: 18px; +} + +
+
+
+

+ 1 Others ... +

+
+
+
, +] +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx new file mode 100644 index 00000000000000..21e5881654533f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { MonitorLocation } from '../../../../../common/runtime_types/monitor'; +import { LocationStatusTags } from '../'; + +describe('StatusByLocation component', () => { + let monitorLocations: MonitorLocation[]; + + const start = moment('2020-01-10T12:22:32.567Z'); + beforeAll(() => { + moment.prototype.fromNow = jest.fn((date: string) => start.from(date)); + }); + + it('renders when there are many location', () => { + monitorLocations = [ + { + summary: { up: 0, down: 1 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.825Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:31.586Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Tokya', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:25.771Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'New York', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:27.485Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Toronto', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.815Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Sydney', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.132Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Paris', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.973Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when all locations are up', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-08T12:22:28.825Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when all locations are down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 2 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-06T12:22:32.567Z', + }, + { + summary: { up: 0, down: 2 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.825Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx index 1f4b88b971c4ce..140d33bbeef66f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx @@ -5,3 +5,4 @@ */ export * from './location_map'; +export * from './location_status_tags'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx index a10d8e02e68638..6563c03ad7c34f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx @@ -7,9 +7,16 @@ import React, { useContext } from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n/react'; import { UptimeSettingsContext } from '../../../contexts'; import { MonitorLocation } from '../../../../common/runtime_types'; +const TimeStampSpan = styled.span` + display: inline-block; + margin-left: 4px; +`; + const TextStyle = styled.div` font-weight: 600; `; @@ -20,54 +27,97 @@ const BadgeItem = styled.div` const TagContainer = styled.div` padding: 10px; - max-height: 200px; + max-height: 229px; overflow: hidden; `; +const OtherLocationsDiv = styled.div` + padding-left: 18px; +`; + interface Props { locations: MonitorLocation[]; } +interface StatusTag { + label: string; + timestamp: number; +} + export const LocationStatusTags = ({ locations }: Props) => { const { colors: { gray, danger }, } = useContext(UptimeSettingsContext); - const upLocs: string[] = []; - const downLocs: string[] = []; + const upLocations: StatusTag[] = []; + const downLocations: StatusTag[] = []; locations.forEach((item: any) => { if (item.summary.down === 0) { - upLocs.push(item.geo.name); + upLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); } else { - downLocs.push(item.geo.name); + downLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); } }); + // Sort by recent timestamp + upLocations.sort((a, b) => { + return a.timestamp < b.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0; + }); + + moment.locale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: '%ds', + ss: '%ss', + m: '%dm', + mm: '%dm', + h: '%dh', + hh: '%dh', + d: '%dd', + dd: '%dd', + M: '%d Mon', + MM: '%d Mon', + y: '%d Yr', + yy: '%d Yr', + }, + }); + + const tagLabel = (item: StatusTag, ind: number, color: string) => ( + + + + {item.label} + + + + {moment(item.timestamp).fromNow()} + + + ); + return ( - - - {downLocs.map((item, ind) => ( - - - - {item} - - - - ))} - - - {upLocs.map((item, ind) => ( - - - - {item} - - - - ))} - - + <> + + {downLocations.map((item, ind) => tagLabel(item, ind, danger))} + {upLocations.map((item, ind) => tagLabel(item, ind, gray))} + + {locations.length > 7 && ( + + +

+ +

+
+
+ )} + ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap new file mode 100644 index 00000000000000..0cb0a7ec248df1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorStatusBar component renders 1`] = ` +Array [ +
, +
+ SSL certificate expires + + + + in 2 months + + + +
, +] +`; + +exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx new file mode 100644 index 00000000000000..2eae14301fd4d9 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EuiBadge } from '@elastic/eui'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { PingTls } from '../../../../../common/graphql/types'; +import { MonitorSSLCertificate } from '../monitor_status_bar'; + +describe('MonitorStatusBar component', () => { + let monitorTls: PingTls; + + beforeEach(() => { + const dateInTwoMonths = moment() + .add(2, 'month') + .toString(); + + monitorTls = { + certificate_not_valid_after: dateInTwoMonths, + }; + }); + + it('renders', () => { + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders null if invalid date', () => { + monitorTls = { + certificate_not_valid_after: 'i am so invalid date', + }; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders expiration date with a warning state if ssl expiry date is less than 30 days', () => { + const dateIn15Days = moment() + .add(15, 'day') + .toString(); + monitorTls = { + certificate_not_valid_after: dateIn15Days, + }; + const component = mountWithIntl(); + + const badgeComponent = component.find(EuiBadge); + expect(badgeComponent.props().color).toBe('warning'); + + const badgeComponentText = component.find('.euiBadge__text'); + expect(badgeComponentText.text()).toBe(moment(dateIn15Days).fromNow()); + + expect(badgeComponent.find('span.euiBadge--warning')).toBeTruthy(); + }); + + it('does not render the expiration date with a warning state if expiry date is greater than a month', () => { + const dateIn40Days = moment() + .add(40, 'day') + .toString(); + monitorTls = { + certificate_not_valid_after: dateIn40Days, + }; + const component = mountWithIntl(); + + const badgeComponent = component.find(EuiBadge); + expect(badgeComponent.props().color).toBe('default'); + + const badgeComponentText = component.find('.euiBadge__text'); + expect(badgeComponentText.text()).toBe(moment(dateIn40Days).fromNow()); + + expect(badgeComponent.find('span.euiBadge--warning')).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx index 4e515a52b8de6f..38864103564ca3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx @@ -17,6 +17,7 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 4, down: 0 }, @@ -32,6 +33,7 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -43,6 +45,7 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -54,10 +57,12 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 0, down: 4 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -69,10 +74,12 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 4, down: 0 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx index 5e916c40e712d7..c57348c4ab4cd4 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx @@ -5,9 +5,8 @@ */ import React from 'react'; -import { get } from 'lodash'; import moment from 'moment'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -21,30 +20,37 @@ interface Props { } export const MonitorSSLCertificate = ({ tls }: Props) => { - const certificateValidity: string | undefined = get( - tls, - 'certificate_not_valid_after', - undefined - ); + const certValidityDate = new Date(tls?.certificate_not_valid_after ?? ''); - const validExpiryDate = certificateValidity && !isNaN(new Date(certificateValidity).valueOf()); + const isValidDate = !isNaN(certValidityDate.valueOf()); - return validExpiryDate && certificateValidity ? ( + const dateIn30Days = moment().add('30', 'days'); + + const isExpiringInMonth = isValidDate && dateIn30Days > moment(certValidityDate); + + return isValidDate ? ( <> + {moment(certValidityDate).fromNow()} + + ), }} /> diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index 37a9e032cd442d..b237fd8771f581 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts @@ -334,7 +334,7 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { order: 'desc', }, }, - _source: ['monitor', 'summary', 'observer'], + _source: ['monitor', 'summary', 'observer', '@timestamp'], }, }, }, @@ -365,6 +365,7 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { const location: MonitorLocation = { summary: mostRecentLocation?.summary, geo: getGeo(mostRecentLocation?.observer?.geo), + timestamp: mostRecentLocation['@timestamp'], }; monLocs.push(location); } diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js index 6f496dd9ee1381..3225653acbb3d5 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js @@ -25,6 +25,7 @@ export class WebhookAction extends BaseAction { this.username = get(props, 'username'); this.password = get(props, 'password'); this.contentType = get(props, 'contentType'); + this.fullPath = `${this.host}:${this.port}${this.path ? '/' + this.path : ''}`; } diff --git a/x-pack/package.json b/x-pack/package.json index 1e20157831ba58..3f826030ac16b1 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -42,6 +42,7 @@ "@storybook/react": "^5.2.6", "@storybook/theming": "^5.2.6", "@testing-library/react": "^9.3.2", + "@testing-library/react-hooks": "^3.2.1", "@testing-library/jest-dom": "4.2.0", "@types/angular": "^1.6.56", "@types/archiver": "^3.0.0", @@ -309,6 +310,7 @@ "react-shortcuts": "^2.0.0", "react-sticky": "^6.0.3", "react-syntax-highlighter": "^5.7.0", + "react-use": "^13.13.0", "react-vis": "^1.8.1", "react-visibility-sensor": "^5.1.1", "recompose": "^0.26.0", diff --git a/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx index 90393f9f4ff6f1..9880a2b811f8b8 100644 --- a/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx @@ -137,7 +137,7 @@ export class CustomizeTimeRangeModal extends Component {i18n.translate( diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index b0e10d245e0b95..e301d157d2c7c7 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -16,6 +16,7 @@ export const config = { }, schema: schema.object({ serviceMapEnabled: schema.boolean({ defaultValue: false }), + serviceMapInitialTimeRange: schema.number({ defaultValue: 60 * 1000 * 60 }), // last 1 hour autocreateApmIndexPattern: schema.boolean({ defaultValue: true }), ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -37,6 +38,7 @@ export function mergeConfigs(apmOssConfig: APMOSSConfig, apmConfig: APMXPackConf 'apm_oss.onboardingIndices': apmOssConfig.onboardingIndices, 'apm_oss.indexPattern': apmOssConfig.indexPattern, 'xpack.apm.serviceMapEnabled': apmConfig.serviceMapEnabled, + 'xpack.apm.serviceMapInitialTimeRange': apmConfig.serviceMapInitialTimeRange, 'xpack.apm.ui.enabled': apmConfig.ui.enabled, 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts new file mode 100644 index 00000000000000..c96856d09256b0 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initializeESFieldsRoute } from './es_fields'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; +import { + httpServiceMock, + httpServerMock, + loggingServiceMock, + elasticsearchServiceMock, +} from 'src/core/server/mocks'; + +const mockRouteContext = ({ + core: { + elasticsearch: { dataClient: elasticsearchServiceMock.createScopedClusterClient() }, + }, +} as unknown) as RequestHandlerContext; + +const path = `api/canvas/workpad/find`; + +describe('Retrieve ES Fields', () => { + let routeHandler: RequestHandler; + + beforeEach(() => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + initializeESFieldsRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.get.mock.calls[0][1]; + }); + + it(`returns 200 with fields from existing index/index pattern`, async () => { + const index = 'test'; + const mockResults = { + indices: ['test'], + fields: { + '@timestamp': { + date: { + type: 'date', + searchable: true, + aggregatable: true, + }, + }, + name: { + text: { + type: 'text', + searchable: true, + aggregatable: false, + }, + }, + products: { + object: { + type: 'object', + searchable: false, + aggregatable: false, + }, + }, + }, + }; + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index, + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockResolvedValueOnce(mockResults); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "@timestamp": "date", + "name": "string", + "products": "unsupported", + } + `); + }); + + it(`returns 200 with empty object when index/index pattern has no fields`, async () => { + const index = 'test'; + const mockResults = { indices: [index], fields: {} }; + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index, + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockResolvedValueOnce(mockResults); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot('Object {}'); + }); + + it(`returns 200 with empty object when index/index pattern does not have specified field(s)`, async () => { + const index = 'test'; + + const mockResults = { + indices: [index], + fields: {}, + }; + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index, + fields: ['foo', 'bar'], + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockResolvedValueOnce(mockResults); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(`Object {}`); + }); + + it(`returns 500 when index does not exist`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index: 'foo', + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockRejectedValueOnce(new Error('Index not found')); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(500); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts new file mode 100644 index 00000000000000..b82f84b931d73f --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues, keys } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { API_ROUTE } from '../../../../../legacy/plugins/canvas/common/lib'; +import { catchErrorHandler } from '../catch_error_handler'; +// @ts-ignore unconverted lib +import { normalizeType } from '../../../../../legacy/plugins/canvas/server/lib/normalize_type'; +import { RouteInitializerDeps } from '..'; + +const ESFieldsRequestSchema = schema.object({ + index: schema.string(), + fields: schema.maybe(schema.arrayOf(schema.string())), +}); + +export function initializeESFieldsRoute(deps: RouteInitializerDeps) { + const { router } = deps; + + router.get( + { + path: `${API_ROUTE}/es_fields`, + validate: { + query: ESFieldsRequestSchema, + }, + }, + catchErrorHandler(async (context, request, response) => { + const { callAsCurrentUser } = context.core.elasticsearch.dataClient; + const { index, fields } = request.query; + + const config = { + index, + fields: fields || '*', + }; + + const esFields = await callAsCurrentUser('fieldCaps', config).then(resp => { + return mapValues(resp.fields, types => { + if (keys(types).length > 1) { + return 'conflict'; + } + + try { + return normalizeType(keys(types)[0]); + } catch (e) { + return 'unsupported'; + } + }); + }); + + return response.ok({ + body: esFields, + }); + }) + ); +} diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts b/x-pack/plugins/canvas/server/routes/es_fields/index.ts similarity index 55% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts rename to x-pack/plugins/canvas/server/routes/es_fields/index.ts index 006d23da56f822..fa44f09747d6ce 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts +++ b/x-pack/plugins/canvas/server/routes/es_fields/index.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BehaviorSubject } from 'rxjs'; +import { initializeESFieldsRoute } from './es_fields'; +import { RouteInitializerDeps } from '..'; -export const severity$: BehaviorSubject<{ - val: number; - display: string; - color: string; -}>; +export function initESFieldsRoutes(deps: RouteInitializerDeps) { + initializeESFieldsRoute(deps); +} diff --git a/x-pack/plugins/canvas/server/routes/index.ts b/x-pack/plugins/canvas/server/routes/index.ts index 8b2d77d6347609..e9afab5680332d 100644 --- a/x-pack/plugins/canvas/server/routes/index.ts +++ b/x-pack/plugins/canvas/server/routes/index.ts @@ -7,6 +7,7 @@ import { IRouter, Logger } from 'src/core/server'; import { initWorkpadRoutes } from './workpad'; import { initCustomElementsRoutes } from './custom_elements'; +import { initESFieldsRoutes } from './es_fields'; export interface RouteInitializerDeps { router: IRouter; @@ -16,4 +17,5 @@ export interface RouteInitializerDeps { export function initRoutes(deps: RouteInitializerDeps) { initWorkpadRoutes(deps); initCustomElementsRoutes(deps); + initESFieldsRoutes(deps); } diff --git a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts index 0bcb161575901b..0c31f517a74b3c 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts @@ -24,11 +24,13 @@ export const WorkpadElementSchema = schema.object({ export const WorkpadPageSchema = schema.object({ elements: schema.arrayOf(WorkpadElementSchema), - groups: schema.arrayOf( - schema.object({ - id: schema.string(), - position: PositionSchema, - }) + groups: schema.maybe( + schema.arrayOf( + schema.object({ + id: schema.string(), + position: PositionSchema, + }) + ) ), id: schema.string(), style: schema.recordOf(schema.string(), schema.string()), diff --git a/x-pack/plugins/task_manager/kibana.json b/x-pack/plugins/task_manager/kibana.json new file mode 100644 index 00000000000000..ad2d5d00ae0be5 --- /dev/null +++ b/x-pack/plugins/task_manager/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "taskManager", + "server": true, + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "task_manager"], + "ui": false +} diff --git a/x-pack/legacy/plugins/task_manager/server/README.md b/x-pack/plugins/task_manager/server/README.md similarity index 68% rename from x-pack/legacy/plugins/task_manager/server/README.md rename to x-pack/plugins/task_manager/server/README.md index 3afcb758260c01..a067358dc8841e 100644 --- a/x-pack/legacy/plugins/task_manager/server/README.md +++ b/x-pack/plugins/task_manager/server/README.md @@ -55,51 +55,61 @@ Plugins define tasks by calling the `registerTaskDefinitions` method on the `ser A sample task can be found in the [x-pack/test/plugin_api_integration/plugins/task_manager](../../test/plugin_api_integration/plugins/task_manager/index.js) folder. ```js -const taskManager = server.plugins.task_manager; -taskManager.registerTaskDefinitions({ - // clusterMonitoring is the task type, and must be unique across the entire system - clusterMonitoring: { - // Human friendly name, used to represent this task in logs, UI, etc - title: 'Human friendly name', - - // Optional, human-friendly, more detailed description - description: 'Amazing!!', - - // Optional, how long, in minutes or seconds, the system should wait before - // a running instance of this task is considered to be timed out. - // This defaults to 5 minutes. - timeout: '5m', - - // Optional, how many attempts before marking task as failed. - // This defaults to what is configured at the task manager level. - maxAttempts: 5, - - // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, - // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is - // overridden by the `override_num_workers` config value, if specified. - numWorkers: 2, - - // The createTaskRunner function / method returns an object that is responsible for - // performing the work of the task. context: { taskInstance }, is documented below. - createTaskRunner(context) { - return { - // Perform the work of the task. The return value should fit the TaskResult interface, documented - // below. Invalid return values will result in a logged warning. - async run() { - // Do some work - // Conditionally send some alerts - // Return some result or other... +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + taskManager.registerTaskDefinitions({ + // clusterMonitoring is the task type, and must be unique across the entire system + clusterMonitoring: { + // Human friendly name, used to represent this task in logs, UI, etc + title: 'Human friendly name', + + // Optional, human-friendly, more detailed description + description: 'Amazing!!', + + // Optional, how long, in minutes or seconds, the system should wait before + // a running instance of this task is considered to be timed out. + // This defaults to 5 minutes. + timeout: '5m', + + // Optional, how many attempts before marking task as failed. + // This defaults to what is configured at the task manager level. + maxAttempts: 5, + + // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, + // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is + // overridden by the `override_num_workers` config value, if specified. + numWorkers: 2, + + // The createTaskRunner function / method returns an object that is responsible for + // performing the work of the task. context: { taskInstance }, is documented below. + createTaskRunner(context) { + return { + // Perform the work of the task. The return value should fit the TaskResult interface, documented + // below. Invalid return values will result in a logged warning. + async run() { + // Do some work + // Conditionally send some alerts + // Return some result or other... + }, + + // Optional, will be called if a running instance of this task times out, allowing the task + // to attempt to clean itself up. + async cancel() { + // Do whatever is required to cancel this task, such as killing any spawned processes + }, + }; }, + }, + }); + } - // Optional, will be called if a running instance of this task times out, allowing the task - // to attempt to clean itself up. - async cancel() { - // Do whatever is required to cancel this task, such as killing any spawned processes - }, - }; - }, - }, -}); + public start(core: CoreStart, plugins: { taskManager }) { + + } +} ``` When Kibana attempts to claim and run a task instance, it looks its definition up, and executes its createTaskRunner's method, passing it a run context which looks like this: @@ -222,67 +232,129 @@ The data stored for a task instance looks something like this: The task manager mixin exposes a taskManager object on the Kibana server which plugins can use to manage scheduled tasks. Each method takes an optional `scope` argument and ensures that only tasks with the specified scope(s) will be affected. -### schedule -Using `schedule` you can instruct TaskManger to schedule an instance of a TaskType at some point in the future. +### Overview +Interaction with the TaskManager Plugin is done via the Kibana Platform Plugin system. +When developing your Plugin, you're asked to define a `setup` method and a `start` method. +These methods are handed Kibana's Plugin APIs for these two stages, which means you'll have access to the following apis in these two stages: + +#### Setup +The _Setup_ Plugin api includes methods which configure Task Manager to support your Plugin's requirements, such as defining custom Middleware and Task Definitions. +```js +{ + addMiddleware: (middleware: Middleware) => { + // ... + }, + registerTaskDefinitions: (taskDefinitions: TaskDictionary) => { + // ... + }, +} +``` + +#### Start +The _Start_ Plugin api allow you to use Task Manager to facilitate your Plugin's behaviour, such as scheduling tasks. ```js -const taskManager = server.plugins.task_manager; -// Schedules a task. All properties are as documented in the previous -// storage section, except that here, params is an object, not a JSON -// string. -const task = await taskManager.schedule({ - taskType, - runAt, - schedule, - params, - scope: ['my-fanci-app'], -}); - -// Removes the specified task -await manager.remove(task.id); - -// Fetches tasks, supports pagination, via the search-after API: -// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-after.html -// If scope is not specified, all tasks are returned, otherwise only tasks -// with the given scope are returned. -const results = await manager.find({ scope: 'my-fanci-app', searchAfter: ['ids'] }); - -// results look something like this: { - searchAfter: ['233322'], - // Tasks is an array of task instances - tasks: [{ - id: '3242342', - taskType: 'reporting', - // etc - }] + fetch: (opts: FetchOpts) => { + // ... + }, + remove: (id: string) => { + // ... + }, + schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => { + // ... + }, + runNow: (taskId: string) => { + // ... + }, + ensureScheduled: (taskInstance: TaskInstanceWithId, options?: any) => { + // ... + }, } ``` -### ensureScheduling +### Detailed APIs + +#### schedule +Using `schedule` you can instruct TaskManger to schedule an instance of a TaskType at some point in the future. + + +```js +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + } + + public start(core: CoreStart, plugins: { taskManager }) { + // Schedules a task. All properties are as documented in the previous + // storage section, except that here, params is an object, not a JSON + // string. + const task = await taskManager.schedule({ + taskType, + runAt, + schedule, + params, + scope: ['my-fanci-app'], + }); + + // Removes the specified task + await taskManager.remove(task.id); + + // Fetches tasks, supports pagination, via the search-after API: + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-after.html + // If scope is not specified, all tasks are returned, otherwise only tasks + // with the given scope are returned. + const results = await taskManager.find({ scope: 'my-fanci-app', searchAfter: ['ids'] }); + } +} +``` +*results* then look something like this: + +```json + { + "searchAfter": ["233322"], + // Tasks is an array of task instances + "tasks": [{ + "id": "3242342", + "taskType": "reporting", + // etc + }] + } +``` + +#### ensureScheduling When using the `schedule` api to schedule a Task you can provide a hard coded `id` on the Task. This tells TaskManager to use this `id` to identify the Task Instance rather than generate an `id` on its own. The danger is that in such a situation, a Task with that same `id` might already have been scheduled at some earlier point, and this would result in an error. In some cases, this is the expected behavior, but often you only care about ensuring the task has been _scheduled_ and don't need it to be scheduled a fresh. To achieve this you should use the `ensureScheduling` api which has the exact same behavior as `schedule`, except it allows the scheduling of a Task with an `id` that's already in assigned to another Task and it will assume that the existing Task is the one you wished to `schedule`, treating this as a successful operation. -### runNow +#### runNow Using `runNow` you can instruct TaskManger to run an existing task on-demand, without waiting for its scheduled time to be reached. ```js -const taskManager = server.plugins.task_manager; - -try { - const taskRunResult = await taskManager.runNow('91760f10-ba42-de9799'); - // If no error is thrown, the task has completed successfully. -} catch(err: Error) { - // If running the task has failed, we throw an error with an appropriate message. - // For example, if the requested task doesnt exist: `Error: failed to run task "91760f10-ba42-de9799" as it does not exist` - // Or if, for example, the task is already running: `Error: failed to run task "91760f10-ba42-de9799" as it is currently running` +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + } + + public start(core: CoreStart, plugins: { taskManager }) { + try { + const taskRunResult = await taskManager.runNow('91760f10-ba42-de9799'); + // If no error is thrown, the task has completed successfully. + } catch(err: Error) { + // If running the task has failed, we throw an error with an appropriate message. + // For example, if the requested task doesnt exist: `Error: failed to run task "91760f10-ba42-de9799" as it does not exist` + // Or if, for example, the task is already running: `Error: failed to run task "91760f10-ba42-de9799" as it is currently running` + } + } } ``` - -### more options +#### more options More custom access to the tasks can be done directly via Elasticsearch, though that won't be officially supported, as we can change the document structure at any time. @@ -291,35 +363,44 @@ More custom access to the tasks can be done directly via Elasticsearch, though t The task manager exposes a middleware layer that allows modifying tasks before they are scheduled / persisted to the task manager index, and modifying tasks / the run context before a task is run. For example: - ```js -// In your plugin's init -server.plugins.task_manager.addMiddleware({ - async beforeSave({ taskInstance, ...opts }) { - console.log(`About to save a task of type ${taskInstance.taskType}`); - - return { - ...opts, - taskInstance: { - ...taskInstance, - params: { - ...taskInstance.params, - example: 'Added to params!', - }, +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + taskManager.addMiddleware({ + async beforeSave({ taskInstance, ...opts }) { + console.log(`About to save a task of type ${taskInstance.taskType}`); + + return { + ...opts, + taskInstance: { + ...taskInstance, + params: { + ...taskInstance.params, + example: 'Added to params!', + }, + }, + }; }, - }; - }, - async beforeRun({ taskInstance, ...opts }) { - console.log(`About to run ${taskInstance.taskType} ${taskInstance.id}`); - const { example, ...taskWithoutExampleProp } = taskInstance; + async beforeRun({ taskInstance, ...opts }) { + console.log(`About to run ${taskInstance.taskType} ${taskInstance.id}`); + const { example, ...taskWithoutExampleProp } = taskInstance; - return { - ...opts, - taskInstance: taskWithoutExampleProp, - }; - }, -}); + return { + ...opts, + taskInstance: taskWithoutExampleProp, + }; + }, + }); + } + + public start(core: CoreStart, plugins: { taskManager }) { + + } +} ``` ## Task Poller: polling for work diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts new file mode 100644 index 00000000000000..f7962f7011f34d --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.test.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 { configSchema } from './config'; + +describe('config validation', () => { + test('task manager defaults', () => { + const config: Record = {}; + expect(configSchema.validate(config)).toMatchInlineSnapshot(` + Object { + "enabled": true, + "index": ".kibana_task_manager", + "max_attempts": 3, + "max_workers": 10, + "poll_interval": 3000, + "request_capacity": 1000, + } + `); + }); + + test('the ElastiSearch Tasks index cannot be used for task manager', () => { + const config: Record = { + index: '.tasks', + }; + expect(() => { + configSchema.validate(config); + }).toThrowErrorMatchingInlineSnapshot( + `"[index]: \\".tasks\\" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager"` + ); + }); +}); diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts new file mode 100644 index 00000000000000..06e6ad3e62282c --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + /* The maximum number of times a task will be attempted before being abandoned as failed */ + max_attempts: schema.number({ + defaultValue: 3, + min: 1, + }), + /* How often, in milliseconds, the task manager will look for more work. */ + poll_interval: schema.number({ + defaultValue: 3000, + min: 100, + }), + /* How many requests can Task Manager buffer before it rejects new requests. */ + request_capacity: schema.number({ + // a nice round contrived number, feel free to change as we learn how it behaves + defaultValue: 1000, + min: 1, + }), + /* The name of the index used to store task information. */ + index: schema.string({ + defaultValue: '.kibana_task_manager', + validate: val => { + if (val.toLowerCase() === '.tasks') { + return `"${val}" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager`; + } + }, + }), + /* The maximum number of tasks that this Kibana instance will run simultaneously. */ + max_workers: schema.number({ + defaultValue: 10, + // disable the task manager rather than trying to specify it with 0 workers + min: 1, + }), +}); + +export type TaskManagerConfig = TypeOf; diff --git a/x-pack/plugins/task_manager/server/create_task_manager.test.ts b/x-pack/plugins/task_manager/server/create_task_manager.test.ts new file mode 100644 index 00000000000000..f4deeb1ea02eda --- /dev/null +++ b/x-pack/plugins/task_manager/server/create_task_manager.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTaskManager, LegacyDeps } from './create_task_manager'; +import { mockLogger } from './test_utils'; +import { CoreSetup, UuidServiceSetup } from 'kibana/server'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; + +jest.mock('./task_manager'); + +describe('createTaskManager', () => { + const uuid: UuidServiceSetup = { + getInstanceUuid() { + return 'some-uuid'; + }, + }; + const mockCoreSetup = { + uuid, + } as CoreSetup; + + const getMockLegacyDeps = (): LegacyDeps => ({ + config: {}, + savedObjectSchemas: {}, + elasticsearch: { + callAsInternalUser: jest.fn(), + }, + savedObjectsRepository: savedObjectsRepositoryMock.create(), + logger: mockLogger(), + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('exposes the underlying TaskManager', async () => { + const mockLegacyDeps = getMockLegacyDeps(); + const setupResult = createTaskManager(mockCoreSetup, mockLegacyDeps); + expect(setupResult).toMatchInlineSnapshot(` + TaskManager { + "addMiddleware": [MockFunction], + "assertUninitialized": [MockFunction], + "attemptToRun": [MockFunction], + "ensureScheduled": [MockFunction], + "fetch": [MockFunction], + "registerTaskDefinitions": [MockFunction], + "remove": [MockFunction], + "runNow": [MockFunction], + "schedule": [MockFunction], + "start": [MockFunction], + "stop": [MockFunction], + "waitUntilStarted": [MockFunction], + } + `); + }); +}); diff --git a/x-pack/plugins/task_manager/server/create_task_manager.ts b/x-pack/plugins/task_manager/server/create_task_manager.ts new file mode 100644 index 00000000000000..5c66b8ba5bd58f --- /dev/null +++ b/x-pack/plugins/task_manager/server/create_task_manager.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 { + IClusterClient, + SavedObjectsSerializer, + SavedObjectsSchema, + CoreSetup, + ISavedObjectsRepository, +} from '../../../../src/core/server'; +import { TaskManager } from './task_manager'; +import { Logger } from './types'; + +export interface LegacyDeps { + config: any; + savedObjectSchemas: any; + elasticsearch: Pick; + savedObjectsRepository: ISavedObjectsRepository; + logger: Logger; +} + +export function createTaskManager( + core: CoreSetup, + { + logger, + config, + savedObjectSchemas, + elasticsearch: { callAsInternalUser }, + savedObjectsRepository, + }: LegacyDeps +) { + // as we use this Schema solely to interact with Tasks, we + // can initialise it with solely the Tasks schema + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema(savedObjectSchemas)); + return new TaskManager({ + taskManagerId: core.uuid.getInstanceUuid(), + config, + savedObjectsRepository, + serializer, + callAsInternalUser, + logger, + }); +} diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts new file mode 100644 index 00000000000000..7eba218e16fed9 --- /dev/null +++ b/x-pack/plugins/task_manager/server/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { TaskManagerPlugin } from './plugin'; +import { configSchema } from './config'; + +export const plugin = (initContext: PluginInitializerContext) => new TaskManagerPlugin(initContext); + +export { + TaskInstance, + ConcreteTaskInstance, + TaskRunCreatorFunction, + TaskStatus, + RunContext, +} from './task'; + +export { + TaskManagerPlugin as TaskManager, + TaskManagerSetupContract, + TaskManagerStartContract, +} from './plugin'; + +export const config = { + schema: configSchema, +}; diff --git a/x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts b/x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts rename to x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.ts b/x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.ts rename to x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/fill_pool.test.ts rename to x-pack/plugins/task_manager/server/lib/fill_pool.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/fill_pool.ts rename to x-pack/plugins/task_manager/server/lib/fill_pool.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/get_template_version.test.ts b/x-pack/plugins/task_manager/server/lib/get_template_version.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/get_template_version.test.ts rename to x-pack/plugins/task_manager/server/lib/get_template_version.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/get_template_version.ts b/x-pack/plugins/task_manager/server/lib/get_template_version.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/get_template_version.ts rename to x-pack/plugins/task_manager/server/lib/get_template_version.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.test.ts b/x-pack/plugins/task_manager/server/lib/identify_es_error.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.test.ts rename to x-pack/plugins/task_manager/server/lib/identify_es_error.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.ts b/x-pack/plugins/task_manager/server/lib/identify_es_error.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.ts rename to x-pack/plugins/task_manager/server/lib/identify_es_error.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/intervals.test.ts b/x-pack/plugins/task_manager/server/lib/intervals.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/intervals.test.ts rename to x-pack/plugins/task_manager/server/lib/intervals.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/intervals.ts b/x-pack/plugins/task_manager/server/lib/intervals.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/intervals.ts rename to x-pack/plugins/task_manager/server/lib/intervals.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/middleware.test.ts b/x-pack/plugins/task_manager/server/lib/middleware.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/middleware.test.ts rename to x-pack/plugins/task_manager/server/lib/middleware.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/middleware.ts b/x-pack/plugins/task_manager/server/lib/middleware.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/middleware.ts rename to x-pack/plugins/task_manager/server/lib/middleware.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.test.ts b/x-pack/plugins/task_manager/server/lib/pull_from_set.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.test.ts rename to x-pack/plugins/task_manager/server/lib/pull_from_set.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.ts b/x-pack/plugins/task_manager/server/lib/pull_from_set.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.ts rename to x-pack/plugins/task_manager/server/lib/pull_from_set.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/result_type.ts b/x-pack/plugins/task_manager/server/lib/result_type.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/result_type.ts rename to x-pack/plugins/task_manager/server/lib/result_type.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts b/x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts rename to x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.ts b/x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.ts rename to x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.ts diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts new file mode 100644 index 00000000000000..9bdd1ce6d87485 --- /dev/null +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { Observable, Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { once } from 'lodash'; +import { TaskDictionary, TaskDefinition } from './task'; +import { TaskManager } from './task_manager'; +import { createTaskManager, LegacyDeps } from './create_task_manager'; +import { TaskManagerConfig } from './config'; +import { Middleware } from './lib/middleware'; + +export type PluginLegacyDependencies = Pick; +export type TaskManagerSetupContract = { + config$: Observable; + registerLegacyAPI: (legacyDependencies: PluginLegacyDependencies) => Promise; +} & Pick; + +export type TaskManagerStartContract = Pick< + TaskManager, + 'fetch' | 'remove' | 'schedule' | 'runNow' | 'ensureScheduled' +>; + +export class TaskManagerPlugin + implements Plugin { + legacyTaskManager$: Subject = new Subject(); + taskManager: Promise = this.legacyTaskManager$.pipe(first()).toPromise(); + currentConfig: TaskManagerConfig; + + constructor(private readonly initContext: PluginInitializerContext) { + this.initContext = initContext; + this.currentConfig = {} as TaskManagerConfig; + } + + public setup(core: CoreSetup, plugins: any): TaskManagerSetupContract { + const logger = this.initContext.logger.get('taskManager'); + const config$ = this.initContext.config.create(); + const savedObjectsRepository = core.savedObjects.createInternalRepository(['task']); + const elasticsearch = core.elasticsearch.adminClient; + return { + config$, + registerLegacyAPI: once((__LEGACY: PluginLegacyDependencies) => { + config$.subscribe(async config => { + this.legacyTaskManager$.next( + createTaskManager(core, { + logger, + config, + elasticsearch, + savedObjectsRepository, + ...__LEGACY, + }) + ); + this.legacyTaskManager$.complete(); + }); + return this.taskManager; + }), + addMiddleware: (middleware: Middleware) => { + this.taskManager.then(tm => tm.addMiddleware(middleware)); + }, + registerTaskDefinitions: (taskDefinition: TaskDictionary) => { + this.taskManager.then(tm => tm.registerTaskDefinitions(taskDefinition)); + }, + }; + } + + public start(): TaskManagerStartContract { + return { + fetch: (...args) => this.taskManager.then(tm => tm.fetch(...args)), + remove: (...args) => this.taskManager.then(tm => tm.remove(...args)), + schedule: (...args) => this.taskManager.then(tm => tm.schedule(...args)), + runNow: (...args) => this.taskManager.then(tm => tm.runNow(...args)), + ensureScheduled: (...args) => this.taskManager.then(tm => tm.ensureScheduled(...args)), + }; + } + public stop() { + this.taskManager.then(tm => { + tm.stop(); + }); + } +} diff --git a/x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts rename to x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts rename to x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts diff --git a/x-pack/legacy/plugins/task_manager/server/queries/query_clauses.ts b/x-pack/plugins/task_manager/server/queries/query_clauses.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/query_clauses.ts rename to x-pack/plugins/task_manager/server/queries/query_clauses.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task.ts rename to x-pack/plugins/task_manager/server/task.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_events.ts rename to x-pack/plugins/task_manager/server/task_events.ts diff --git a/x-pack/plugins/task_manager/server/task_manager.mock.ts b/x-pack/plugins/task_manager/server/task_manager.mock.ts new file mode 100644 index 00000000000000..9750dd14100d9b --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_manager.mock.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TaskManagerSetupContract, TaskManagerStartContract } from './plugin'; +import { Subject } from 'rxjs'; + +export const taskManagerMock = { + setup(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + config$: new Subject(), + registerLegacyAPI: jest.fn(), + ...overrides, + }; + return mocked; + }, + start(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + ensureScheduled: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + runNow: jest.fn(), + remove: jest.fn(), + ...overrides, + }; + return mocked; + }, +}; diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.test.ts b/x-pack/plugins/task_manager/server/task_manager.test.ts similarity index 94% rename from x-pack/legacy/plugins/task_manager/server/task_manager.test.ts rename to x-pack/plugins/task_manager/server/task_manager.test.ts index 51c3e5b81d7649..a65723b2e8de7f 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.test.ts +++ b/x-pack/plugins/task_manager/server/task_manager.test.ts @@ -20,39 +20,33 @@ import { awaitTaskRunResult, TaskLifecycleEvent, } from './task_manager'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../../src/core/server'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; +import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../src/core/server'; import { mockLogger } from './test_utils'; import { asErr, asOk } from './lib/result_type'; import { ConcreteTaskInstance, TaskLifecycleResult, TaskStatus } from './task'; -const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsClient = savedObjectsRepositoryMock.create(); const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); describe('TaskManager', () => { let clock: sinon.SinonFakeTimers; - const defaultConfig = { - xpack: { - task_manager: { - max_workers: 10, - index: 'foo', - max_attempts: 9, - poll_interval: 6000000, - }, - }, - server: { - uuid: 'some-uuid', - }, - }; + const config = { - get: (path: string) => _.get(defaultConfig, path), + enabled: true, + max_workers: 10, + index: 'foo', + max_attempts: 9, + poll_interval: 6000000, + request_capacity: 1000, }; const taskManagerOpts = { config, savedObjectsRepository: savedObjectsClient, serializer, - callWithInternalUser: jest.fn(), + callAsInternalUser: jest.fn(), logger: mockLogger(), + taskManagerId: 'some-uuid', }; beforeEach(() => { @@ -63,21 +57,9 @@ describe('TaskManager', () => { test('throws if no valid UUID is available', async () => { expect(() => { - const configWithoutServerUUID = { - xpack: { - task_manager: { - max_workers: 10, - index: 'foo', - max_attempts: 9, - poll_interval: 6000000, - }, - }, - }; new TaskManager({ ...taskManagerOpts, - config: { - get: (path: string) => _.get(configWithoutServerUUID, path), - }, + taskManagerId: '', }); }).toThrowErrorMatchingInlineSnapshot( `"TaskManager is unable to start as Kibana has no valid UUID assigned to it."` @@ -234,7 +216,7 @@ describe('TaskManager', () => { test('allows and queues fetching tasks before starting', async () => { const client = new TaskManager(taskManagerOpts); - taskManagerOpts.callWithInternalUser.mockResolvedValue({ + taskManagerOpts.callAsInternalUser.mockResolvedValue({ hits: { total: { value: 0, @@ -245,13 +227,13 @@ describe('TaskManager', () => { const promise = client.fetch({}); client.start(); await promise; - expect(taskManagerOpts.callWithInternalUser).toHaveBeenCalled(); + expect(taskManagerOpts.callAsInternalUser).toHaveBeenCalled(); }); test('allows fetching tasks after starting', async () => { const client = new TaskManager(taskManagerOpts); client.start(); - taskManagerOpts.callWithInternalUser.mockResolvedValue({ + taskManagerOpts.callAsInternalUser.mockResolvedValue({ hits: { total: { value: 0, @@ -260,7 +242,7 @@ describe('TaskManager', () => { }, }); await client.fetch({}); - expect(taskManagerOpts.callWithInternalUser).toHaveBeenCalled(); + expect(taskManagerOpts.callAsInternalUser).toHaveBeenCalled(); }); test('allows middleware registration before starting', () => { @@ -282,7 +264,6 @@ describe('TaskManager', () => { }; client.start(); - expect(() => client.addMiddleware(middleware)).toThrow( /Cannot add middleware after the task manager is initialized/i ); diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.ts b/x-pack/plugins/task_manager/server/task_manager.ts similarity index 95% rename from x-pack/legacy/plugins/task_manager/server/task_manager.ts rename to x-pack/plugins/task_manager/server/task_manager.ts index 6c9191ffe3d0e8..c0baed3708a0a0 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.ts +++ b/x-pack/plugins/task_manager/server/task_manager.ts @@ -10,8 +10,13 @@ import { performance } from 'perf_hooks'; import { pipe } from 'fp-ts/lib/pipeable'; import { Option, none, some, map as mapOptional } from 'fp-ts/lib/Option'; -import { SavedObjectsClientContract, SavedObjectsSerializer } from '../../../../../src/core/server'; +import { + SavedObjectsSerializer, + IScopedClusterClient, + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { Result, asErr, either, map, mapErr, promiseResult } from './lib/result_type'; +import { TaskManagerConfig } from './config'; import { Logger } from './types'; import { @@ -56,10 +61,11 @@ const VERSION_CONFLICT_STATUS = 409; export interface TaskManagerOpts { logger: Logger; - config: any; - callWithInternalUser: any; - savedObjectsRepository: SavedObjectsClientContract; + config: TaskManagerConfig; + callAsInternalUser: IScopedClusterClient['callAsInternalUser']; + savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; + taskManagerId: string; } interface RunNowResult { @@ -110,7 +116,7 @@ export class TaskManager { constructor(opts: TaskManagerOpts) { this.logger = opts.logger; - const taskManagerId = opts.config.get('server.uuid'); + const { taskManagerId } = opts; if (!taskManagerId) { this.logger.error( `TaskManager is unable to start as there the Kibana UUID is invalid (value of the "server.uuid" configuration is ${taskManagerId})` @@ -123,9 +129,9 @@ export class TaskManager { this.store = new TaskStore({ serializer: opts.serializer, savedObjectsRepository: opts.savedObjectsRepository, - callCluster: opts.callWithInternalUser, - index: opts.config.get('xpack.task_manager.index'), - maxAttempts: opts.config.get('xpack.task_manager.max_attempts'), + callCluster: opts.callAsInternalUser, + index: opts.config.index, + maxAttempts: opts.config.max_attempts, definitions: this.definitions, taskManagerId: `kibana:${taskManagerId}`, }); @@ -134,12 +140,12 @@ export class TaskManager { this.pool = new TaskPool({ logger: this.logger, - maxWorkers: opts.config.get('xpack.task_manager.max_workers'), + maxWorkers: opts.config.max_workers, }); this.poller$ = createTaskPoller({ - pollInterval: opts.config.get('xpack.task_manager.poll_interval'), - bufferCapacity: opts.config.get('xpack.task_manager.request_capacity'), + pollInterval: opts.config.poll_interval, + bufferCapacity: opts.config.request_capacity, getCapacity: () => this.pool.availableWorkers, pollRequests$: this.claimRequests$, work: this.pollForWork, diff --git a/x-pack/legacy/plugins/task_manager/server/task_poller.test.ts b/x-pack/plugins/task_manager/server/task_poller.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_poller.test.ts rename to x-pack/plugins/task_manager/server/task_poller.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_poller.ts b/x-pack/plugins/task_manager/server/task_poller.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_poller.ts rename to x-pack/plugins/task_manager/server/task_poller.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_pool.test.ts rename to x-pack/plugins/task_manager/server/task_pool.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_pool.ts rename to x-pack/plugins/task_manager/server/task_pool.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_runner.test.ts similarity index 99% rename from x-pack/legacy/plugins/task_manager/server/task_runner.test.ts rename to x-pack/plugins/task_manager/server/task_runner.test.ts index 3f7877aa4c20f8..3f0132105347e7 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_runner.test.ts @@ -12,7 +12,7 @@ import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent } from './task_events import { ConcreteTaskInstance, TaskStatus } from './task'; import { TaskManagerRunner } from './task_runner'; import { mockLogger } from './test_utils'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; let fakeTimer: sinon.SinonFakeTimers; diff --git a/x-pack/legacy/plugins/task_manager/server/task_runner.ts b/x-pack/plugins/task_manager/server/task_runner.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_runner.ts rename to x-pack/plugins/task_manager/server/task_runner.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts similarity index 99% rename from x-pack/legacy/plugins/task_manager/server/task_store.test.ts rename to x-pack/plugins/task_manager/server/task_store.test.ts index c7a0a1a0207215..f47cc41c2d0457 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -17,13 +17,13 @@ import { TaskLifecycleResult, } from './task'; import { FetchOpts, StoreOpts, OwnershipClaimingOpts, TaskStore } from './task_store'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; import { SavedObjectsSerializer, SavedObjectsSchema, SavedObjectAttributes, -} from '../../../../../src/core/server'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server/saved_objects/service/lib/errors'; +} from '../../../../src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server/saved_objects/service/lib/errors'; import { asTaskClaimEvent, TaskEvent } from './task_events'; import { asOk, asErr } from './lib/result_type'; @@ -45,7 +45,7 @@ const taskDefinitions: TaskDictionary = { }, }; -const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsClient = savedObjectsRepositoryMock.create(); const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); beforeEach(() => jest.resetAllMocks()); diff --git a/x-pack/legacy/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts similarity index 98% rename from x-pack/legacy/plugins/task_manager/server/task_store.ts rename to x-pack/plugins/task_manager/server/task_store.ts index e8fc0ccb909367..f4695b152237a2 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -11,12 +11,12 @@ import { Subject, Observable } from 'rxjs'; import { omit, difference } from 'lodash'; import { - SavedObjectsClientContract, SavedObject, SavedObjectAttributes, SavedObjectsSerializer, SavedObjectsRawDoc, -} from '../../../../../src/core/server'; + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { asOk, asErr } from './lib/result_type'; @@ -60,7 +60,7 @@ export interface StoreOpts { taskManagerId: string; maxAttempts: number; definitions: TaskDictionary; - savedObjectsRepository: SavedObjectsClientContract; + savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; } @@ -123,7 +123,7 @@ export class TaskStore { private callCluster: ElasticJs; private definitions: TaskDictionary; - private savedObjectsRepository: SavedObjectsClientContract; + private savedObjectsRepository: ISavedObjectsRepository; private serializer: SavedObjectsSerializer; private events$: Subject; diff --git a/x-pack/legacy/plugins/task_manager/server/test_utils/index.ts b/x-pack/plugins/task_manager/server/test_utils/index.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/test_utils/index.ts rename to x-pack/plugins/task_manager/server/test_utils/index.ts diff --git a/x-pack/legacy/plugins/task_manager/server/types.ts b/x-pack/plugins/task_manager/server/types.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/types.ts rename to x-pack/plugins/task_manager/server/types.ts diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3b0c188318309d..5661020ba6fa6a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6075,8 +6075,6 @@ "xpack.infra.logs.highlights.goToPreviousHighlightButtonLabel": "前のハイライトにスキップ", "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent": "機械学習ロボットがデータの収集を開始するまでしばらくお待ちください。", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessTitle": "成功!", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", @@ -11825,8 +11823,6 @@ "xpack.uptime.kueryBar.searchPlaceholder": "モニター ID、名前、プロトコルタイプなどを検索…", "xpack.uptime.monitorList.noItemForSelectedFiltersMessage": "選択されたフィルター条件でモニターが見つかりませんでした", "xpack.uptime.monitorList.table.description": "列にステータス、名前、URL、IP、ダウンタイム履歴、統合が入力されたモニターステータス表です。この表は現在 {length} 項目を表示しています。", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.ariaLabel": "SSL 証明書の有効期限:", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.content": "SSL 証明書の有効期限: {certificateValidity}", "xpack.uptime.notFountPage.homeLinkText": "ホームへ戻る", "xpack.uptime.overviewPageLink.disabled.ariaLabel": "無効になったページ付けボタンです。モニターリストがこれ以上ナビゲーションできないことを示しています。", "xpack.uptime.overviewPageLink.next.ariaLabel": "次の結果ページ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3cc476937d4e72..1bcfab4240aeda 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6077,8 +6077,6 @@ "xpack.infra.logs.highlights.goToPreviousHighlightButtonLabel": "跳转到上一高亮条目", "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent": "请注意,我们的 Machine Learning 机器人若干分钟后才会开始收集数据。", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessTitle": "成功!", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", @@ -11914,8 +11912,6 @@ "xpack.uptime.kueryBar.searchPlaceholder": "搜索监测 ID、名称和协议类型......", "xpack.uptime.monitorList.noItemForSelectedFiltersMessage": "未找到匹配选定筛选条件的监测", "xpack.uptime.monitorList.table.description": "具有“状态”、“名称”、“URL”、“IP”、“中断历史记录”和“集成”列的“监测状态”表。该表当前显示 {length} 个项目。", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.ariaLabel": "SSL 证书过期", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.content": "SSL 证书于 {certificateValidity} 过期", "xpack.uptime.notFountPage.homeLinkText": "返回主页", "xpack.uptime.overviewPageLink.disabled.ariaLabel": "禁用的分页按钮表示在监测列表中无法进行进一步导航。", "xpack.uptime.overviewPageLink.next.ariaLabel": "下页结果", diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts index 3bfad59b71166a..29708f86b0a9b0 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TaskManagerStartContract } from '../../../../../../plugins/task_manager/server'; + const taskManagerQuery = (...filters: any[]) => ({ bool: { filter: { @@ -38,7 +40,7 @@ export default function(kibana: any) { }, init(server: any) { - const taskManager = server.plugins.task_manager; + const taskManager = server.newPlatform.start.plugins.taskManager as TaskManagerStartContract; server.route({ path: '/api/alerting_tasks/{taskId}', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 551498e22d5c8c..d20450f8ec47e9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -761,7 +761,8 @@ export default function alertTests({ getService }: FtrProviderContext) { } }); - it(`should unmute all instances when unmuting an alert`, async () => { + // Flaky: https://github.com/elastic/kibana/issues/54125 + it.skip(`should unmute all instances when unmuting an alert`, async () => { const testStart = new Date(); const reference = alertUtils.generateReference(); const response = await alertUtils.createAlwaysFiringAction({ diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts index f148d62421ff8b..ad4f81777e7804 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts @@ -67,9 +67,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -125,9 +123,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -178,9 +174,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts index 9ac6d4fdef19f4..ee58be76928b3a 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts index 9190e0b4886ce3..e2d5efac4644cc 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts @@ -60,10 +60,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows apm navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'APM', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['APM', 'Management']); }); it('can navigate to APM app', async () => { @@ -111,9 +108,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows apm navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['APM', 'Management']); }); @@ -166,9 +161,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show APM navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts index 191ba5c4d1e25b..1ac1784e0e05db 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('APM'); }); @@ -61,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/canvas/expression.ts b/x-pack/test/functional/apps/canvas/expression.ts new file mode 100644 index 00000000000000..fc6b80468b9f2c --- /dev/null +++ b/x-pack/test/functional/apps/canvas/expression.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function canvasExpressionTest({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + // const browser = getService('browser'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['canvas', 'common']); + const find = getService('find'); + + describe('expression editor', function() { + // there is an issue with FF not properly clicking on workpad elements + this.tags('skipFirefox'); + + before(async () => { + // init data + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.load('canvas/default'); + + // load test workpad + await PageObjects.common.navigateToApp('canvas', { + hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', + }); + }); + + it('updates when element is changed via side bar', async () => { + // wait for all our elements to load up + await retry.try(async () => { + const elements = await testSubjects.findAll( + 'canvasWorkpadPage > canvasWorkpadPageElementContent' + ); + expect(elements).to.have.length(4); + }); + + // find the first workpad element (a markdown element) and click it to select it + await testSubjects.click('canvasWorkpadPage > canvasWorkpadPageElementContent', 20000); + + // open the expression editor + await PageObjects.canvas.openExpressionEditor(); + + // select markdown content and clear it + const mdBox = await find.byCssSelector('.canvasSidebar__panel .canvasTextArea__code'); + const oldMd = await mdBox.getVisibleText(); + await mdBox.clearValueWithKeyboard(); + + // type the new text + const newMd = `${oldMd} and this is a test`; + await mdBox.type(newMd); + await find.clickByCssSelector('.canvasArg--controls .euiButton'); + + // make sure the open expression editor also has the changes + const editor = await find.byCssSelector('.monaco-editor .view-lines'); + const editorText = await editor.getVisibleText(); + expect(editorText).to.contain('Orange: Timelion, Server function and this is a test'); + + // reset the markdown + await mdBox.clearValueWithKeyboard(); + await mdBox.type(oldMd); + await find.clickByCssSelector('.canvasArg--controls .euiButton'); + }); + }); +} diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts index a58eb61ec4ca2b..d0e37ec8e3f359 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts @@ -65,9 +65,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows canvas navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Canvas', 'Management']); }); @@ -143,9 +141,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows canvas navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Canvas', 'Management']); }); diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts index 5a6857901536ff..28b572401892b5 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Canvas'); }); @@ -98,9 +96,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Canvas'); }); diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index bc33161cc4e970..fa4e362b6bc590 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -8,6 +8,7 @@ export default function canvasApp({ loadTestFile }) { describe('Canvas app', function canvasAppTestSuite() { this.tags('ciGroup2'); // CI requires tags ヽ(゜Q。)ノ? loadTestFile(require.resolve('./smoke_test')); + loadTestFile(require.resolve('./expression')); loadTestFile(require.resolve('./feature_controls/canvas_security')); loadTestFile(require.resolve('./feature_controls/canvas_spaces')); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index aa6860b35763f8..d25fae3c4894cd 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -75,10 +75,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Dashboard', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Dashboard', 'Management']); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -255,9 +252,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows dashboard navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Dashboard', 'Management']); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts index c1197fa7023c51..ebe08a60c25636 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts @@ -43,9 +43,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dashboard'); }); @@ -107,9 +105,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dashboard'); }); diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js new file mode 100644 index 00000000000000..c90a0ae6d19fc4 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js @@ -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. + */ + +export default function({ getPageObjects, getService }) { + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens']); + + describe('empty dashboard', function() { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + after(async () => { + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + async function createAndAddLens(title) { + log.debug(`createAndAddLens(${title})`); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + await PageObjects.visualize.clickLensWidget(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_xDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_yDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_splitDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'terms', + field: 'ip', + }); + await PageObjects.lens.save(title); + } + + it('adds Lens visualization to empty dashboard', async () => { + const title = 'Dashboard Test Lens'; + await testSubjects.exists('addVisualizationButton'); + await testSubjects.click('addVisualizationButton'); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddLens(title); + await PageObjects.dashboard.waitForRenderComplete(); + await testSubjects.exists(`embeddablePanelHeading-${title}`); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard_mode/index.js b/x-pack/test/functional/apps/dashboard_mode/index.js index 2a98634ba40d5d..09b9717ea9f02b 100644 --- a/x-pack/test/functional/apps/dashboard_mode/index.js +++ b/x-pack/test/functional/apps/dashboard_mode/index.js @@ -9,5 +9,6 @@ export default function({ loadTestFile }) { this.tags('ciGroup7'); loadTestFile(require.resolve('./dashboard_view_mode')); + loadTestFile(require.resolve('./dashboard_empty_screen')); }); } diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts index fd7739e6930d01..494fd71ea6f34a 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts @@ -63,10 +63,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows Dev Tools navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Dev Tools', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Dev Tools', 'Management']); }); describe('console', () => { @@ -146,9 +143,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`shows 'Dev Tools' navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Dev Tools', 'Management']); }); diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts index e3bc3a1c6ce11c..4184d223a96864 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dev Tools'); }); @@ -79,9 +77,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dev Tools'); }); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 553ce459ebb182..1912b16d96f36c 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -81,10 +81,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Discover', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Discover', 'Management']); }); it('shows save button', async () => { @@ -170,9 +167,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows discover navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts index 3e5dcd7b0c9877..e6b6f28f8b92fc 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts @@ -49,9 +49,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Discover'); }); @@ -93,9 +91,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Discover'); }); diff --git a/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts b/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts index 1d1fb566eb075f..d8eb969b99b3b1 100644 --- a/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts +++ b/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('EEndpoint'); }); @@ -70,9 +68,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('EEndpoint'); }); }); diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts index acc8943033a1a8..a2b062e6ef84fb 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts @@ -64,10 +64,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows graph navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Graph', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Graph', 'Management']); }); it('landing page shows "Create new graph" button', async () => { @@ -129,9 +126,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows graph navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Graph', 'Management']); }); @@ -183,9 +178,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show graph navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Graph'); }); diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts index 0945b35ba09309..a0b0d5bef96680 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts @@ -34,9 +34,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Graph'); }); @@ -75,9 +73,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Graph'); }); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts index 4929bb52c170c4..30cdc95b38e62e 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts @@ -69,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -125,9 +123,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -179,9 +175,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts index bc8542288410c0..6a2b77de17f457 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts index 4d61e0996419cb..5062f094061c03 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts @@ -60,9 +60,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows metrics navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Metrics', 'Management']); }); @@ -175,9 +173,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows metrics navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Metrics', 'Management']); }); @@ -417,9 +413,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show metrics navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain(['Metrics']); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts index 300b22e5bcbc32..7c2a11a542d66e 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts @@ -48,9 +48,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Metrics'); }); @@ -101,9 +99,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Metrics'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts index d092e6736656ec..b9634c29dac1c9 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts @@ -57,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows logs navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Logs', 'Management']); }); @@ -122,9 +120,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows logs navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Logs', 'Management']); }); @@ -187,9 +183,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show logs navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain('Logs'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts index 8230b25efbbf9b..6b078d2cfa71af 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts @@ -36,9 +36,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Logs'); }); @@ -77,9 +75,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain('Logs'); }); diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts index 8b2df502dc100d..8fb6f21c778d3f 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts @@ -80,9 +80,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show ml navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Machine Learning'); }); }); @@ -103,9 +101,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows ML navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Machine Learning'); }); }); diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts index 13036737218bc3..fc94688e98811b 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts @@ -39,9 +39,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Machine Learning'); }); @@ -71,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Machine Learning'); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts index cf31f445a96f3f..804ad5725edfd3 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts @@ -65,9 +65,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Maps', 'Management']); }); @@ -154,9 +152,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Maps', 'Management']); }); @@ -251,9 +247,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('does not show Maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts index 0c86b47b373e6a..e157586aecead7 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts @@ -42,9 +42,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Maps'); }); diff --git a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts index 8848df83d36d6e..d985da42ab5eda 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts @@ -76,9 +76,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show monitoring navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); }); @@ -99,9 +97,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows monitoring navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); }); diff --git a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts index 80f33ff6175c56..7459b53ca4a32f 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts @@ -41,9 +41,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); @@ -74,9 +72,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); diff --git a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts index 46f0be1e6f6d65..1e79c76bf83e5e 100644 --- a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts +++ b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts @@ -55,9 +55,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); @@ -131,9 +129,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts index 64fb218a62c80f..dea45f161e4510 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts @@ -59,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows timelion navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Timelion', 'Management']); }); @@ -113,9 +111,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows timelion navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Timelion', 'Management']); }); diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts index ea5e255071dadd..fb203a23359bdd 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts @@ -38,9 +38,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Timelion'); }); @@ -71,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Timelion'); }); diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts index c5a597cdaffb0c..a004f8db66823c 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts @@ -64,10 +64,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows uptime navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Uptime', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Uptime', 'Management']); }); it('can navigate to Uptime app', async () => { @@ -117,9 +114,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows uptime navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Uptime', 'Management']); }); @@ -170,9 +165,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show uptime navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts index 96bc3c5f74f598..77c5b323340bf8 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Uptime'); }); @@ -59,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index 86fe606ecafad5..d55076cb0ab43b 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -74,9 +74,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows visualize navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Visualize', 'Management']); }); @@ -190,9 +188,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows visualize navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Visualize', 'Management']); }); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts index d0fdc7c95ea380..9193862d2ba9e4 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Visualize'); }); @@ -81,9 +79,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Visualize'); }); diff --git a/x-pack/test/functional/page_objects/canvas_page.ts b/x-pack/test/functional/page_objects/canvas_page.ts index a4b4f500b8832c..fa117dbea393de 100644 --- a/x-pack/test/functional/page_objects/canvas_page.ts +++ b/x-pack/test/functional/page_objects/canvas_page.ts @@ -23,6 +23,10 @@ export function CanvasPageProvider({ getService }: FtrProviderContext) { await browser.pressKeys(browser.keys.ESCAPE); }, + async openExpressionEditor() { + await testSubjects.click('canvasExpressionEditorButton'); + }, + async waitForWorkpadElements() { await testSubjects.findAll('canvasWorkpadPage > canvasWorkpadPageElementContent'); }, diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/index.js b/x-pack/test/plugin_api_integration/plugins/task_manager/index.js index b0e46543b4e763..50fb9571c26879 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/index.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/index.js @@ -28,7 +28,11 @@ export default function TaskTestingAPI(kibana) { }, init(server) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; + const legacyTaskManager = server.plugins.task_manager; const defaultSampleTaskConfig = { timeout: '1m', @@ -128,7 +132,7 @@ export default function TaskTestingAPI(kibana) { }, }); - initRoutes(server, taskTestingEvents); + initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents); }, }); } diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js index 3330d08dfd0d2b..c0dcd99525915b 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js @@ -23,9 +23,7 @@ const taskManagerQuery = { }, }; -export function initRoutes(server, taskTestingEvents) { - const taskManager = server.plugins.task_manager; - +export function initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents) { server.route({ path: '/api/sample_tasks/schedule', method: 'POST', @@ -62,6 +60,45 @@ export function initRoutes(server, taskTestingEvents) { }, }); + /* + Schedule using legacy Api + */ + server.route({ + path: '/api/sample_tasks/schedule_legacy', + method: 'POST', + config: { + validate: { + payload: Joi.object({ + task: Joi.object({ + taskType: Joi.string().required(), + schedule: Joi.object({ + interval: Joi.string(), + }).optional(), + interval: Joi.string().optional(), + params: Joi.object().required(), + state: Joi.object().optional(), + id: Joi.string().optional(), + }), + }), + }, + }, + async handler(request) { + try { + const { task: taskFields } = request.payload; + const task = { + ...taskFields, + scope: [scope], + }; + + const taskResult = await legacyTaskManager.schedule(task, { request }); + + return taskResult; + } catch (err) { + return err; + } + }, + }); + server.route({ path: '/api/sample_tasks/run_now', method: 'POST', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js index ff06bee83d51d8..0b1c1cbb5af29c 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js @@ -74,6 +74,15 @@ export default function({ getService }) { .then(response => response.body); } + function scheduleTaskUsingLegacyApi(task) { + return supertest + .post('/api/sample_tasks/schedule_legacy') + .set('kbn-xsrf', 'xxx') + .send({ task }) + .expect(200) + .then(response => response.body); + } + function runTaskNow(task) { return supertest .post('/api/sample_tasks/run_now') @@ -494,5 +503,15 @@ export default function({ getService }) { expect(getTaskById(tasks, longRunningTask.id).state.count).to.eql(1); }); }); + + it('should retain the legacy api until v8.0.0', async () => { + const result = await scheduleTaskUsingLegacyApi({ + id: 'task-with-legacy-api', + taskType: 'sampleTask', + params: {}, + }); + + expect(result.id).to.be('task-with-legacy-api'); + }); }); } diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js index c3cd582fd59c42..87e3b3b66a2019 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js @@ -23,7 +23,10 @@ export default function TaskManagerPerformanceAPI(kibana) { }, init(server) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; const performanceState = resetPerfState({}); let lastFlush = new Date(); diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js index ca6d8707f5c58f..6cd706a6ebecdf 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js @@ -9,7 +9,10 @@ import { range, chunk } from 'lodash'; const scope = 'perf-testing'; export function initRoutes(server, performanceState) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; server.route({ path: '/api/perf_tasks', diff --git a/x-pack/test/typings/hapi.d.ts b/x-pack/test/typings/hapi.d.ts index 0400c1b7d8f232..fc5ce09e5e6188 100644 --- a/x-pack/test/typings/hapi.d.ts +++ b/x-pack/test/typings/hapi.d.ts @@ -9,7 +9,6 @@ import 'hapi'; import { XPackMainPlugin } from '../../legacy/plugins/xpack_main/server/xpack_main'; import { SecurityPlugin } from '../../legacy/plugins/security'; import { ActionsPlugin, ActionsClient } from '../../legacy/plugins/actions'; -import { TaskManager } from '../../legacy/plugins/task_manager/server'; import { AlertingPlugin, AlertsClient } from '../../legacy/plugins/alerting'; declare module 'hapi' { @@ -22,6 +21,5 @@ declare module 'hapi' { security?: SecurityPlugin; actions?: ActionsPlugin; alerting?: AlertingPlugin; - task_manager?: TaskManager; } } diff --git a/x-pack/typings/hapi.d.ts b/x-pack/typings/hapi.d.ts index cfc1a641550fcd..a739d5f884f6e9 100644 --- a/x-pack/typings/hapi.d.ts +++ b/x-pack/typings/hapi.d.ts @@ -9,8 +9,8 @@ import 'hapi'; import { XPackMainPlugin } from '../legacy/plugins/xpack_main/server/xpack_main'; import { SecurityPlugin } from '../legacy/plugins/security'; import { ActionsPlugin, ActionsClient } from '../legacy/plugins/actions'; -import { TaskManager } from '../legacy/plugins/task_manager/server'; import { AlertingPlugin, AlertsClient } from '../legacy/plugins/alerting'; +import { LegacyTaskManagerApi } from '../legacy/plugins/task_manager/server'; declare module 'hapi' { interface Request { @@ -22,6 +22,6 @@ declare module 'hapi' { security?: SecurityPlugin; actions?: ActionsPlugin; alerting?: AlertingPlugin; - task_manager?: TaskManager; + task_manager?: LegacyTaskManagerApi; } } diff --git a/yarn.lock b/yarn.lock index 96bb533120aa74..ff098b7b9c891f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3439,6 +3439,11 @@ resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050" integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A== +"@types/deep-freeze-strict@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/deep-freeze-strict/-/deep-freeze-strict-1.1.0.tgz#447a6a2576191344aa42310131dd3df5c41492c4" + integrity sha1-RHpqJXYZE0SqQjEBMd099cQUksQ= + "@types/delete-empty@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" @@ -10034,6 +10039,11 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +deep-freeze-strict@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" + integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA= + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -10915,9 +10925,9 @@ element-resize-detector@^1.1.15: batch-processor "^1.0.0" elliptic@^6.0.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" - integrity sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8= + version "6.5.2" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" + integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== dependencies: bn.js "^4.4.0" brorand "^1.0.1"