diff --git a/.eslintrc.js b/.eslintrc.js index 24b04f8be511f..a7bb204da4775 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -82,12 +82,6 @@ module.exports = { 'react-hooks/exhaustive-deps': 'off', }, }, - { - files: ['src/legacy/core_plugins/vis_type_markdown/**/*.{js,ts,tsx}'], - rules: { - 'react-hooks/exhaustive-deps': 'off', - }, - }, { files: ['src/legacy/core_plugins/vis_type_table/**/*.{js,ts,tsx}'], rules: { @@ -347,9 +341,8 @@ module.exports = { 'src/fixtures/**/*.js', // TODO: this directory needs to be more obviously "public" (or go away) ], settings: { - // instructs import/no-extraneous-dependencies to treat modules - // in plugins/ or ui/ namespace as "core modules" so they don't - // trigger failures for not being listed in package.json + // instructs import/no-extraneous-dependencies to treat certain modules + // as core modules, even if they aren't listed in package.json 'import/core-modules': [ 'plugins', 'legacy/ui', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a0a22446ba31d..9a4f2b71da1ff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -86,6 +86,7 @@ /packages/kbn-es/ @elastic/kibana-operations /packages/kbn-pm/ @elastic/kibana-operations /packages/kbn-test/ @elastic/kibana-operations +/packages/kbn-ui-shared-deps/ @elastic/kibana-operations /src/legacy/server/keystore/ @elastic/kibana-operations /src/legacy/server/pid/ @elastic/kibana-operations /src/legacy/server/sass/ @elastic/kibana-operations diff --git a/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md new file mode 100644 index 0000000000000..ddbf9aafbd28a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [chromeless](./kibana-plugin-public.appbase.chromeless.md) + +## AppBase.chromeless property + +Hide the UI chrome when the application is mounted. Defaults to `false`. Takes precedence over chrome service visibility settings. + +Signature: + +```typescript +chromeless?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.id.md b/docs/development/core/public/kibana-plugin-public.appbase.id.md index 57daa0c94bdf6..89dd32d296104 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.id.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.id.md @@ -4,6 +4,8 @@ ## AppBase.id property +The unique identifier of the application + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-public.appbase.md b/docs/development/core/public/kibana-plugin-public.appbase.md index a93a195c559b1..eb6d91cb92488 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.md @@ -16,10 +16,14 @@ export interface AppBase | Property | Type | Description | | --- | --- | --- | | [capabilities](./kibana-plugin-public.appbase.capabilities.md) | Partial<Capabilities> | Custom capabilities defined by the app. | +| [chromeless](./kibana-plugin-public.appbase.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | | [icon](./kibana-plugin-public.appbase.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | -| [id](./kibana-plugin-public.appbase.id.md) | string | | +| [id](./kibana-plugin-public.appbase.id.md) | string | The unique identifier of the application | +| [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) | AppNavLinkStatus | The initial status of the application's navLink. Defaulting to visible if status is accessible and hidden if status is inaccessible See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | | [order](./kibana-plugin-public.appbase.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [status](./kibana-plugin-public.appbase.status.md) | AppStatus | The initial status of the application. Defaulting to accessible | | [title](./kibana-plugin-public.appbase.title.md) | string | The title of the application. | -| [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) | Observable<string> | An observable for a tooltip shown when hovering over app link. | +| [tooltip](./kibana-plugin-public.appbase.tooltip.md) | string | A tooltip shown when hovering over app link. | +| [updater$](./kibana-plugin-public.appbase.updater_.md) | Observable<AppUpdater> | An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. | diff --git a/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md new file mode 100644 index 0000000000000..d6744c3e75756 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) + +## AppBase.navLinkStatus property + +The initial status of the application's navLink. Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +Signature: + +```typescript +navLinkStatus?: AppNavLinkStatus; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md b/docs/development/core/public/kibana-plugin-public.appbase.status.md similarity index 56% rename from docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md rename to docs/development/core/public/kibana-plugin-public.appbase.status.md index 0767ead5f1455..a5fbadbeea1ff 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.status.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [status](./kibana-plugin-public.appbase.status.md) -## AppBase.tooltip$ property +## AppBase.status property -An observable for a tooltip shown when hovering over app link. +The initial status of the application. Defaulting to `accessible` Signature: ```typescript -tooltip$?: Observable; +status?: AppStatus; ``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md new file mode 100644 index 0000000000000..85921a5a321dd --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip](./kibana-plugin-public.appbase.tooltip.md) + +## AppBase.tooltip property + +A tooltip shown when hovering over app link. + +Signature: + +```typescript +tooltip?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.updater_.md b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md new file mode 100644 index 0000000000000..3edd357383449 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md @@ -0,0 +1,44 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [updater$](./kibana-plugin-public.appbase.updater_.md) + +## AppBase.updater$ property + +An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. + +Signature: + +```typescript +updater$?: Observable; +``` + +## Example + +How to update an application navLink at runtime + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + }) + } + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.md index a63de399c2ecb..cf9bc5189af40 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.md @@ -16,5 +16,6 @@ export interface ApplicationSetup | Method | Description | | --- | --- | | [register(app)](./kibana-plugin-public.applicationsetup.register.md) | Register an mountable application to the system. | +| [registerAppUpdater(appUpdater$)](./kibana-plugin-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | | [registerMountContext(contextName, provider)](./kibana-plugin-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md new file mode 100644 index 0000000000000..39b4f878a3f79 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md @@ -0,0 +1,47 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerAppUpdater](./kibana-plugin-public.applicationsetup.registerappupdater.md) + +## ApplicationSetup.registerAppUpdater() method + +Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) fields of all applications at runtime. + +This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the `updater$` property of the registered application instead. + +Signature: + +```typescript +registerAppUpdater(appUpdater$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appUpdater$ | Observable<AppUpdater> | | + +Returns: + +`void` + +## Example + +How to register an application updater that disables some applications: + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + setup({ application }) { + application.registerAppUpdater( + new BehaviorSubject(app => { + if (myPluginApi.shouldDisable(app)) + return { + status: AppStatus.inaccessible, + }; + }) + ); + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md new file mode 100644 index 0000000000000..d6b22ac2b9217 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +## AppNavLinkStatus enum + +Status of the application's navLink. + +Signature: + +```typescript +export declare enum AppNavLinkStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| default | 0 | The application navLink will be visible if the application's [AppStatus](./kibana-plugin-public.appstatus.md) is set to accessible and hidden if the application status is set to inaccessible. | +| disabled | 2 | The application navLink is visible but inactive and not clickable in the navigation bar. | +| hidden | 3 | The application navLink does not appear in the navigation bar. | +| visible | 1 | The application navLink is visible and clickable in the navigation bar. | + diff --git a/docs/development/core/public/kibana-plugin-public.appstatus.md b/docs/development/core/public/kibana-plugin-public.appstatus.md new file mode 100644 index 0000000000000..23fb7186569da --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appstatus.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppStatus](./kibana-plugin-public.appstatus.md) + +## AppStatus enum + +Accessibility status of an application. + +Signature: + +```typescript +export declare enum AppStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| accessible | 0 | Application is accessible. | +| inaccessible | 1 | Application is not accessible. | + diff --git a/docs/development/core/public/kibana-plugin-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md new file mode 100644 index 0000000000000..b9260c79cd972 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) + +## AppUpdatableFields type + +Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). + +Signature: + +```typescript +export declare type AppUpdatableFields = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appupdater.md b/docs/development/core/public/kibana-plugin-public.appupdater.md new file mode 100644 index 0000000000000..f1b965cc2fc22 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdater.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdater](./kibana-plugin-public.appupdater.md) + +## AppUpdater type + +Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) + +Signature: + +```typescript +export declare type AppUpdater = (app: AppBase) => Partial | undefined; +``` diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index f03f3457ca93f..64cbdd880fed1 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -1,147 +1,151 @@ - - -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) - -## kibana-plugin-public package - -The Kibana Core APIs for client-side plugins. - -A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). - -The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. - -## Classes - -| Class | Description | -| --- | --- | -| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | -| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | -| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | - -## Enumerations - -| Enumeration | Description | -| --- | --- | -| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | - -## Interfaces - -| Interface | Description | -| --- | --- | -| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | -| [AppBase](./kibana-plugin-public.appbase.md) | | -| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | -| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | -| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | -| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | -| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | -| [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | -| [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | -| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | -| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | -| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | -| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | -| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | -| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | -| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | -| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | -| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | -| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | -| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | -| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | -| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | -| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | -| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | -| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | -| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | -| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | -| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | -| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | -| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | -| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | -| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | -| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | -| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | -| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | -| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | -| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | -| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | -| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | -| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | -| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | -| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | -| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | -| [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | -| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | -| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | -| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | -| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | -| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | -| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | -| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | -| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | -| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | -| [SavedObject](./kibana-plugin-public.savedobject.md) | | -| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | -| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | -| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | -| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | -| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | -| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | -| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | -| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | -| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | -| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | -| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | -| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | -| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | -| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | -| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | -| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | -| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | -| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | -| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | -| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | -| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | -| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | - -## Type Aliases - -| Type Alias | Description | -| --- | --- | -| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | -| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | -| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | -| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | -| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | -| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | -| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | -| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | -| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | -| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | -| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | -| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | -| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | -| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | -| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | -| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | -| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | -| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | -| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | -| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | -| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | -| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | -| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | -| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | -| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | -| [Toast](./kibana-plugin-public.toast.md) | | -| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | -| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | - + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) + +## kibana-plugin-public package + +The Kibana Core APIs for client-side plugins. + +A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). + +The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. + +## Classes + +| Class | Description | +| --- | --- | +| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | +| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | +| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | + +## Enumerations + +| Enumeration | Description | +| --- | --- | +| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | +| [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | Status of the application's navLink. | +| [AppStatus](./kibana-plugin-public.appstatus.md) | Accessibility status of an application. | + +## Interfaces + +| Interface | Description | +| --- | --- | +| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | +| [AppBase](./kibana-plugin-public.appbase.md) | | +| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | +| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | +| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | +| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | +| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | +| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | +| [Capabilities](./kibana-plugin-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | +| [ChromeBadge](./kibana-plugin-public.chromebadge.md) | | +| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | +| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | +| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | +| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | +| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | +| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | +| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | +| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | +| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | +| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | +| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | +| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | +| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | +| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | +| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | +| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | +| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | +| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | +| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | +| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | +| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | +| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | +| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | +| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | +| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | +| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | +| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | +| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | +| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | +| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | +| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | +| [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | +| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | +| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | +| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | +| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | +| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | +| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | +| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | +| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | +| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | +| [SavedObject](./kibana-plugin-public.savedobject.md) | | +| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | +| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | +| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | +| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | +| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | +| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | +| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | +| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | +| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | +| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | +| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | +| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | +| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | +| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | +| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | +| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | +| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | +| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | + +## Type Aliases + +| Type Alias | Description | +| --- | --- | +| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | +| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | +| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | +| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | +| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | +| [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). | +| [AppUpdater](./kibana-plugin-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | +| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | +| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | +| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | +| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | +| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | +| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | +| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | +| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | +| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | +| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | +| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | +| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | +| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | +| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | +| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | +| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | +| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | +| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | +| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | +| [Toast](./kibana-plugin-public.toast.md) | | +| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | +| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | + diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index eab3833b3f5ae..e3d6e0d97c73a 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -37,4 +37,5 @@ cause Kibana's authorization to behave unexpectedly. include::authorization/index.asciidoc[] include::authorization/kibana-privileges.asciidoc[] include::api-keys/index.asciidoc[] +include::role-mappings/index.asciidoc[] include::rbac_tutorial.asciidoc[] diff --git a/docs/user/security/role-mappings/images/role-mappings-create-step-1.png b/docs/user/security/role-mappings/images/role-mappings-create-step-1.png new file mode 100644 index 0000000000000..2b4ad16459529 Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-create-step-1.png differ diff --git a/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif b/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif new file mode 100644 index 0000000000000..0a10126ea3cce Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif differ diff --git a/docs/user/security/role-mappings/images/role-mappings-grid.png b/docs/user/security/role-mappings/images/role-mappings-grid.png new file mode 100644 index 0000000000000..96c9ee8e4cd95 Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-grid.png differ diff --git a/docs/user/security/role-mappings/index.asciidoc b/docs/user/security/role-mappings/index.asciidoc new file mode 100644 index 0000000000000..01028ab4d59e0 --- /dev/null +++ b/docs/user/security/role-mappings/index.asciidoc @@ -0,0 +1,51 @@ +[role="xpack"] +[[role-mappings]] +=== Role mappings + +Role mappings allow you to describe which roles to assign to your users +using a set of rules. Role mappings are required when authenticating via +an external identity provider, such as Active Directory, Kerberos, PKI, OIDC, +or SAML. + +Role mappings have no effect for users inside the `native` or `file` realms. + +To manage your role mappings, use *Management > Security > Role Mappings*. + +With *Role mappings*, you can: + +* View your configured role mappings +* Create/Edit/Delete role mappings + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-grid.png["Role mappings"] + + +[float] +=== Create a role mapping + +To create a role mapping, navigate to *Management > Security > Role Mappings*, and click **Create role mapping**. +Give your role mapping a unique name, and choose which roles you wish to assign to your users. +If you need more flexibility, you can use {ref}/security-api-put-role-mapping.html#_role_templates[role templates] instead. + +Next, define the rules describing which users should receive the roles you defined. Rules can optionally grouped and nested, allowing for sophisticated logic to suite complex requirements. +View the {ref}/role-mapping-resources.html[role mapping resources for an overview of the allowed rule types]. + + +[float] +=== Example + +Let's create a `sales-users` role mapping, which assigns a `sales` role to users whose username +starts with `sls_`, *or* belongs to the `executive` group. + +First, we give the role mapping a name, and assign the `sales` role: + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-create-step-1.png["Create role mapping, step 1"] + +Next, we define the two rules, making sure to set the group to *Any are true*: + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-create-step-2.gif["Create role mapping, step 2"] + +Click *Save role mapping* once you're finished. + diff --git a/docs/visualize/visualize_rollup_data.asciidoc b/docs/visualize/visualize_rollup_data.asciidoc index 110533589cab9..481cbc6e39418 100644 --- a/docs/visualize/visualize_rollup_data.asciidoc +++ b/docs/visualize/visualize_rollup_data.asciidoc @@ -6,7 +6,7 @@ beta[] You can visualize your rolled up data in a variety of charts, tables, maps, and more. Most visualizations support rolled up data, with the exception of -Timelion, TSVB, and Vega visualizations. +Timelion and Vega visualizations. To get started, go to *Management > Kibana > Index patterns.* If a rollup index is detected in the cluster, *Create index pattern* diff --git a/examples/state_containers_examples/kibana.json b/examples/state_containers_examples/kibana.json new file mode 100644 index 0000000000000..9114a414a4da3 --- /dev/null +++ b/examples/state_containers_examples/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "stateContainersExamples", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["state_containers_examples"], + "server": false, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [] +} diff --git a/examples/state_containers_examples/package.json b/examples/state_containers_examples/package.json new file mode 100644 index 0000000000000..b309494a36662 --- /dev/null +++ b/examples/state_containers_examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "state_containers_examples", + "version": "1.0.0", + "main": "target/examples/state_containers_examples", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/examples/state_containers_examples/public/app.tsx b/examples/state_containers_examples/public/app.tsx new file mode 100644 index 0000000000000..319680d07f9bc --- /dev/null +++ b/examples/state_containers_examples/public/app.tsx @@ -0,0 +1,69 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AppMountParameters } from 'kibana/public'; +import ReactDOM from 'react-dom'; +import React from 'react'; +import { createHashHistory, createBrowserHistory } from 'history'; +import { TodoAppPage } from './todo'; + +export interface AppOptions { + appInstanceId: string; + appTitle: string; + historyType: History; +} + +export enum History { + Browser, + Hash, +} + +export const renderApp = ( + { appBasePath, element }: AppMountParameters, + { appInstanceId, appTitle, historyType }: AppOptions +) => { + const history = + historyType === History.Browser + ? createBrowserHistory({ basename: appBasePath }) + : createHashHistory(); + ReactDOM.render( + { + const stripTrailingSlash = (path: string) => + path.charAt(path.length - 1) === '/' ? path.substr(0, path.length - 1) : path; + const currentAppUrl = stripTrailingSlash(history.createHref(history.location)); + if (historyType === History.Browser) { + // browser history + const basePath = stripTrailingSlash(appBasePath); + return currentAppUrl === basePath && !history.location.search && !history.location.hash; + } else { + // hashed history + return currentAppUrl === '#' && !history.location.search; + } + }} + />, + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/webpackShims/jquery.js b/examples/state_containers_examples/public/index.ts similarity index 86% rename from webpackShims/jquery.js rename to examples/state_containers_examples/public/index.ts index da81dd18cf71e..bc7ad78574ddb 100644 --- a/webpackShims/jquery.js +++ b/examples/state_containers_examples/public/index.ts @@ -17,4 +17,6 @@ * under the License. */ -window.jQuery = window.$ = module.exports = require('../node_modules/jquery/dist/jquery'); +import { StateContainersExamplesPlugin } from './plugin'; + +export const plugin = () => new StateContainersExamplesPlugin(); diff --git a/examples/state_containers_examples/public/plugin.ts b/examples/state_containers_examples/public/plugin.ts new file mode 100644 index 0000000000000..beb7b93dbc5b6 --- /dev/null +++ b/examples/state_containers_examples/public/plugin.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AppMountParameters, CoreSetup, Plugin } from 'kibana/public'; + +export class StateContainersExamplesPlugin implements Plugin { + public setup(core: CoreSetup) { + core.application.register({ + id: 'state-containers-example-browser-history', + title: 'State containers example - browser history routing', + async mount(params: AppMountParameters) { + const { renderApp, History } = await import('./app'); + return renderApp(params, { + appInstanceId: '1', + appTitle: 'Routing with browser history', + historyType: History.Browser, + }); + }, + }); + core.application.register({ + id: 'state-containers-example-hash-history', + title: 'State containers example - hash history routing', + async mount(params: AppMountParameters) { + const { renderApp, History } = await import('./app'); + return renderApp(params, { + appInstanceId: '2', + appTitle: 'Routing with hash history', + historyType: History.Hash, + }); + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx new file mode 100644 index 0000000000000..84defb4a91e3f --- /dev/null +++ b/examples/state_containers_examples/public/todo.tsx @@ -0,0 +1,327 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect } from 'react'; +import { Link, Route, Router, Switch, useLocation } from 'react-router-dom'; +import { History } from 'history'; +import { + EuiButton, + EuiCheckbox, + EuiFieldText, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; +import { + BaseStateContainer, + INullableBaseStateContainer, + createKbnUrlStateStorage, + createSessionStorageStateStorage, + createStateContainer, + createStateContainerReactHelpers, + PureTransition, + syncStates, + getStateFromKbnUrl, +} from '../../../src/plugins/kibana_utils/public'; +import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; +import { + defaultState, + pureTransitions, + TodoActions, + TodoState, +} from '../../../src/plugins/kibana_utils/demos/state_containers/todomvc'; + +interface GlobalState { + text: string; +} +interface GlobalStateAction { + setText: PureTransition; +} +const defaultGlobalState: GlobalState = { text: '' }; +const globalStateContainer = createStateContainer( + defaultGlobalState, + { + setText: state => text => ({ ...state, text }), + } +); + +const GlobalStateHelpers = createStateContainerReactHelpers(); + +const container = createStateContainer(defaultState, pureTransitions); +const { Provider, connect, useTransitions, useState } = createStateContainerReactHelpers< + typeof container +>(); + +interface TodoAppProps { + filter: 'completed' | 'not-completed' | null; +} + +const TodoApp: React.FC = ({ filter }) => { + const { setText } = GlobalStateHelpers.useTransitions(); + const { text } = GlobalStateHelpers.useState(); + const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); + const todos = useState(); + const filteredTodos = todos.filter(todo => { + if (!filter) return true; + if (filter === 'completed') return todo.completed; + if (filter === 'not-completed') return !todo.completed; + return true; + }); + const location = useLocation(); + return ( + <> +
+ + + All + + + + + Completed + + + + + Not Completed + + +
+
    + {filteredTodos.map(todo => ( +
  • + { + editTodo({ + ...todo, + completed: e.target.checked, + }); + }} + label={todo.text} + /> + { + deleteTodo(todo.id); + }} + > + Delete + +
  • + ))} +
+
{ + const inputRef = (e.target as HTMLFormElement).elements.namedItem( + 'newTodo' + ) as HTMLInputElement; + if (!inputRef || !inputRef.value) return; + addTodo({ + text: inputRef.value, + completed: false, + id: todos.map(todo => todo.id).reduce((a, b) => Math.max(a, b), 0) + 1, + }); + inputRef.value = ''; + e.preventDefault(); + }} + > + + +
+ + setText(e.target.value)} /> +
+ + ); +}; + +const TodoAppConnected = GlobalStateHelpers.connect(() => ({}))( + connect(() => ({}))(TodoApp) +); + +export const TodoAppPage: React.FC<{ + history: History; + appInstanceId: string; + appTitle: string; + appBasePath: string; + isInitialRoute: () => boolean; +}> = props => { + const initialAppUrl = React.useRef(window.location.href); + const [useHashedUrl, setUseHashedUrl] = React.useState(false); + + /** + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage and tries to restore it on "componentDidMount" + */ + useUrlTracker(`lastUrlTracker:${props.appInstanceId}`, props.history, urlToRestore => { + // shouldRestoreUrl: + // App decides if it should restore url or not + // In this specific case, restore only if navigated to initial route + if (props.isInitialRoute()) { + // navigated to the base path, so should restore the url + return true; + } else { + // navigated to specific route, so should not restore the url + return false; + } + }); + + useEffect(() => { + // have to sync with history passed to react-router + // history v5 will be singleton and this will not be needed + const kbnUrlStateStorage = createKbnUrlStateStorage({ + useHash: useHashedUrl, + history: props.history, + }); + + const sessionStorageStateStorage = createSessionStorageStateStorage(); + + /** + * Restoring global state: + * State restoration similar to what GlobalState in legacy world did + * It restores state both from url and from session storage + */ + const globalStateKey = `_g`; + const globalStateFromInitialUrl = getStateFromKbnUrl( + globalStateKey, + initialAppUrl.current + ); + const globalStateFromCurrentUrl = kbnUrlStateStorage.get(globalStateKey); + const globalStateFromSessionStorage = sessionStorageStateStorage.get( + globalStateKey + ); + + const initialGlobalState: GlobalState = { + ...defaultGlobalState, + ...globalStateFromCurrentUrl, + ...globalStateFromSessionStorage, + ...globalStateFromInitialUrl, + }; + globalStateContainer.set(initialGlobalState); + kbnUrlStateStorage.set(globalStateKey, initialGlobalState, { replace: true }); + sessionStorageStateStorage.set(globalStateKey, initialGlobalState); + + /** + * Restoring app local state: + * State restoration similar to what AppState in legacy world did + * It restores state both from url + */ + const appStateKey = `_todo-${props.appInstanceId}`; + const initialAppState: TodoState = + getStateFromKbnUrl(appStateKey, initialAppUrl.current) || + kbnUrlStateStorage.get(appStateKey) || + defaultState; + container.set(initialAppState); + kbnUrlStateStorage.set(appStateKey, initialAppState, { replace: true }); + + // start syncing only when made sure, that state in synced + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: appStateKey, + stateStorage: kbnUrlStateStorage, + }, + { + stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), + storageKey: globalStateKey, + stateStorage: kbnUrlStateStorage, + }, + { + stateContainer: withDefaultState(globalStateContainer, defaultGlobalState), + storageKey: globalStateKey, + stateStorage: sessionStorageStateStorage, + }, + ]); + + start(); + + return () => { + stop(); + + // reset state containers + container.set(defaultState); + globalStateContainer.set(defaultGlobalState); + }; + }, [props.appInstanceId, props.history, useHashedUrl]); + + return ( + + + + + + + +

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

+
+ setUseHashedUrl(!useHashedUrl)}> + {useHashedUrl ? 'Use Expanded State' : 'Use Hashed State'} + +
+
+ + + + + + + + + + + + + + + +
+
+
+
+ ); +}; + +function withDefaultState( + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + if (Array.isArray(defaultState)) { + stateContainer.set(state || defaultState); + } else { + stateContainer.set({ + ...defaultState, + ...state, + }); + } + }, + }; +} diff --git a/examples/state_containers_examples/tsconfig.json b/examples/state_containers_examples/tsconfig.json new file mode 100644 index 0000000000000..091130487791b --- /dev/null +++ b/examples/state_containers_examples/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/package.json b/package.json index a91e9b80eb49d..0ed74dd65d1ab 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "packages": [ "packages/*", "x-pack", + "x-pack/plugins/*", "x-pack/legacy/plugins/*", "examples/*", "test/plugin_functional/plugins/*", @@ -133,6 +134,7 @@ "@kbn/pm": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", + "@kbn/ui-shared-deps": "1.0.0", "@types/json-stable-stringify": "^1.0.32", "@types/lodash.clonedeep": "^4.5.4", "@types/node-forge": "^0.9.0", @@ -169,7 +171,6 @@ "elastic-apm-node": "^3.2.0", "elasticsearch": "^16.5.0", "elasticsearch-browser": "^16.5.0", - "encode-uri-query": "1.0.1", "execa": "^3.2.0", "expiry-js": "0.1.7", "fast-deep-equal": "^3.1.1", diff --git a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js index c51168ae2d91c..e02c38494991a 100755 --- a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js +++ b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js @@ -30,8 +30,6 @@ exports.getWebpackConfig = function(kibanaPath, projectRoot, config) { ui: fromKibana('src/legacy/ui/public'), test_harness: fromKibana('src/test_harness/public'), querystring: 'querystring-browser', - moment$: fromKibana('webpackShims/moment'), - 'moment-timezone$': fromKibana('webpackShims/moment-timezone'), // Dev defaults for test bundle https://github.com/elastic/kibana/blob/6998f074542e8c7b32955db159d15661aca253d7/src/core_plugins/tests_bundle/index.js#L73-L78 ng_mock$: fromKibana('src/test_utils/public/ng_mock'), diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index c806547595089..a3debf78fb8c8 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -58268,6 +58268,7 @@ function getProjectPaths({ if (!ossOnly) { projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack')); + projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/plugins/*')); projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/legacy/plugins/*')); } diff --git a/packages/kbn-pm/src/config.ts b/packages/kbn-pm/src/config.ts index 6d5e67dca7d13..6ba8d58a26f88 100644 --- a/packages/kbn-pm/src/config.ts +++ b/packages/kbn-pm/src/config.ts @@ -46,6 +46,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option if (!ossOnly) { projectPaths.push(resolve(rootPath, 'x-pack')); + projectPaths.push(resolve(rootPath, 'x-pack/plugins/*')); projectPaths.push(resolve(rootPath, 'x-pack/legacy/plugins/*')); } diff --git a/packages/kbn-ui-shared-deps/README.md b/packages/kbn-ui-shared-deps/README.md new file mode 100644 index 0000000000000..3d3ee37ca5a75 --- /dev/null +++ b/packages/kbn-ui-shared-deps/README.md @@ -0,0 +1,3 @@ +# `@kbn/ui-shared-deps` + +Shared dependencies that must only have a single instance are installed and re-exported from here. To consume them, import the package and merge the `externals` export into your webpack config so that all references to the supported modules will be remapped to use the global versions. \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js new file mode 100644 index 0000000000000..250abd162f91d --- /dev/null +++ b/packages/kbn-ui-shared-deps/entry.js @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// must load before angular +export const Jquery = require('jquery'); +window.$ = window.jQuery = Jquery; + +export const Angular = require('angular'); +export const ElasticCharts = require('@elastic/charts'); +export const ElasticEui = require('@elastic/eui'); +export const ElasticEuiLibServices = require('@elastic/eui/lib/services'); +export const ElasticEuiLightTheme = require('@elastic/eui/dist/eui_theme_light.json'); +export const ElasticEuiDarkTheme = require('@elastic/eui/dist/eui_theme_dark.json'); +export const Moment = require('moment'); +export const MomentTimezone = require('moment-timezone/moment-timezone'); +export const React = require('react'); +export const ReactDom = require('react-dom'); +export const ReactIntl = require('react-intl'); +export const ReactRouter = require('react-router'); // eslint-disable-line +export const ReactRouterDom = require('react-router-dom'); + +// load timezone data into moment-timezone +Moment.tz.load(require('moment-timezone/data/packed/latest.json')); diff --git a/packages/kbn-ui-shared-deps/index.d.ts b/packages/kbn-ui-shared-deps/index.d.ts new file mode 100644 index 0000000000000..132445bbde745 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.d.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Absolute path to the distributable directory + */ +export const distDir: string; + +/** + * Filename of the main bundle file in the distributable directory + */ +export const distFilename: string; + +/** + * Filename of the dark-theme css file in the distributable directory + */ +export const darkCssDistFilename: string; + +/** + * Filename of the light-theme css file in the distributable directory + */ +export const lightCssDistFilename: string; + +/** + * Externals mapping inteded to be used in a webpack config + */ +export const externals: { + [key: string]: string; +}; diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js new file mode 100644 index 0000000000000..cef25295b35d7 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.js @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const Path = require('path'); + +exports.distDir = Path.resolve(__dirname, 'target'); +exports.distFilename = 'kbn-ui-shared-deps.js'; +exports.lightCssDistFilename = 'kbn-ui-shared-deps.light.css'; +exports.darkCssDistFilename = 'kbn-ui-shared-deps.dark.css'; +exports.externals = { + angular: '__kbnSharedDeps__.Angular', + '@elastic/charts': '__kbnSharedDeps__.ElasticCharts', + '@elastic/eui': '__kbnSharedDeps__.ElasticEui', + '@elastic/eui/lib/services': '__kbnSharedDeps__.ElasticEuiLibServices', + '@elastic/eui/dist/eui_theme_light.json': '__kbnSharedDeps__.ElasticEuiLightTheme', + '@elastic/eui/dist/eui_theme_dark.json': '__kbnSharedDeps__.ElasticEuiDarkTheme', + jquery: '__kbnSharedDeps__.Jquery', + moment: '__kbnSharedDeps__.Moment', + 'moment-timezone': '__kbnSharedDeps__.MomentTimezone', + react: '__kbnSharedDeps__.React', + 'react-dom': '__kbnSharedDeps__.ReactDom', + 'react-intl': '__kbnSharedDeps__.ReactIntl', + 'react-router': '__kbnSharedDeps__.ReactRouter', + 'react-router-dom': '__kbnSharedDeps__.ReactRouterDom', +}; diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json new file mode 100644 index 0000000000000..014467d204d96 --- /dev/null +++ b/packages/kbn-ui-shared-deps/package.json @@ -0,0 +1,29 @@ +{ + "name": "@kbn/ui-shared-deps", + "version": "1.0.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build": "node scripts/build", + "kbn:bootstrap": "node scripts/build --dev", + "kbn:watch": "node scripts/build --watch" + }, + "devDependencies": { + "@elastic/eui": "17.3.1", + "@elastic/charts": "^16.1.0", + "@kbn/dev-utils": "1.0.0", + "@yarnpkg/lockfile": "^1.1.0", + "angular": "^1.7.9", + "css-loader": "^2.1.1", + "del": "^5.1.0", + "jquery": "^3.4.1", + "mini-css-extract-plugin": "0.8.0", + "moment": "^2.24.0", + "moment-timezone": "^0.5.27", + "react-dom": "^16.12.0", + "react-intl": "^2.8.0", + "react": "^16.12.0", + "read-pkg": "^5.2.0", + "webpack": "4.41.0" + } +} \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/scripts/build.js b/packages/kbn-ui-shared-deps/scripts/build.js new file mode 100644 index 0000000000000..8b7c22dac24ff --- /dev/null +++ b/packages/kbn-ui-shared-deps/scripts/build.js @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const Path = require('path'); + +const { run, createFailError } = require('@kbn/dev-utils'); +const webpack = require('webpack'); +const Stats = require('webpack/lib/Stats'); +const del = require('del'); + +const { getWebpackConfig } = require('../webpack.config'); + +run( + async ({ log, flags }) => { + log.info('cleaning previous build output'); + await del(Path.resolve(__dirname, '../target')); + + const compiler = webpack( + getWebpackConfig({ + dev: flags.dev, + }) + ); + + /** @param {webpack.Stats} stats */ + const onCompilationComplete = stats => { + const took = Math.round((stats.endTime - stats.startTime) / 1000); + + if (!stats.hasErrors() && !stats.hasWarnings()) { + log.success(`webpack completed in about ${took} seconds`); + return; + } + + throw createFailError( + `webpack failure in about ${took} seconds\n${stats.toString({ + colors: true, + ...Stats.presetToOptions('minimal'), + })}` + ); + }; + + if (flags.watch) { + compiler.hooks.done.tap('report on stats', stats => { + try { + onCompilationComplete(stats); + } catch (error) { + log.error(error.message); + } + }); + + compiler.hooks.watchRun.tap('report on start', () => { + process.stdout.cursorTo(0, 0); + process.stdout.clearScreenDown(); + log.info('Running webpack compilation...'); + }); + + compiler.watch({}, error => { + if (error) { + log.error('Fatal webpack error'); + log.error(error); + process.exit(1); + } + }); + + return; + } + + onCompilationComplete( + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else { + resolve(stats); + } + }); + }) + ); + }, + { + description: 'build @kbn/ui-shared-deps', + flags: { + boolean: ['watch', 'dev'], + help: ` + --watch Run in watch mode + --dev Build development friendly version + `, + }, + } +); diff --git a/packages/kbn-ui-shared-deps/tsconfig.json b/packages/kbn-ui-shared-deps/tsconfig.json new file mode 100644 index 0000000000000..c5c3cba147fcf --- /dev/null +++ b/packages/kbn-ui-shared-deps/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "index.d.ts" + ] +} diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js new file mode 100644 index 0000000000000..87cca2cc897f8 --- /dev/null +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const Path = require('path'); + +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { REPO_ROOT } = require('@kbn/dev-utils'); +const webpack = require('webpack'); + +const SharedDeps = require('./index'); + +const MOMENT_SRC = require.resolve('moment/min/moment-with-locales.js'); + +exports.getWebpackConfig = ({ dev = false } = {}) => ({ + mode: dev ? 'development' : 'production', + entry: { + [SharedDeps.distFilename.replace(/\.js$/, '')]: './entry.js', + [SharedDeps.darkCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_dark.css', + '@elastic/charts/dist/theme_only_dark.css', + ], + [SharedDeps.lightCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_light.css', + '@elastic/charts/dist/theme_only_light.css', + ], + }, + context: __dirname, + devtool: dev ? '#cheap-source-map' : false, + output: { + path: SharedDeps.distDir, + filename: '[name].js', + sourceMapFilename: '[file].map', + publicPath: '__REPLACE_WITH_PUBLIC_PATH__', + devtoolModuleFilenameTemplate: info => + `kbn-ui-shared-deps/${Path.relative(REPO_ROOT, info.absoluteResourcePath)}`, + library: '__kbnSharedDeps__', + }, + + module: { + noParse: [MOMENT_SRC], + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + + resolve: { + alias: { + moment: MOMENT_SRC, + }, + }, + + optimization: { + noEmitOnErrors: true, + }, + + performance: { + // NOTE: we are disabling this as those hints + // are more tailored for the final bundles result + // and not for the webpack compilations performance itself + hints: false, + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: '[name].css', + }), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': dev ? '"development"' : '"production"', + }), + ], +}); diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index b70ac610f24a7..173d73ffab664 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -55,6 +55,7 @@ - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - [On the server side](#on-the-server-side) - [On the client side](#on-the-client-side) + - [Updates an application navlink at runtime](#updates-an-app-navlink-at-runtime) Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. @@ -1624,3 +1625,31 @@ class MyPlugin { It's not currently possible to use a similar pattern on the client-side. Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. + +### Updates an application navlink at runtime + +The application API now provides a way to updates some of a registered application's properties after registration. + +```typescript +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'Application disabled', + }) + } +``` \ No newline at end of file diff --git a/src/core/README.md b/src/core/README.md index 8863658e0040c..f4539191d448b 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -7,6 +7,7 @@ Core Plugin API Documentation: - [Core Public API](/docs/development/core/public/kibana-plugin-public.md) - [Core Server API](/docs/development/core/server/kibana-plugin-server.md) - [Conventions for Plugins](./CONVENTIONS.md) + - [Testing Kibana Plugins](./TESTING.md) - [Migration guide for porting existing plugins](./MIGRATION.md) Internal Documentation: diff --git a/src/core/TESTING.md b/src/core/TESTING.md new file mode 100644 index 0000000000000..6139820d02a14 --- /dev/null +++ b/src/core/TESTING.md @@ -0,0 +1,254 @@ +# Testing Kibana Plugins + +This document outlines best practices and patterns for testing Kibana Plugins. + +- [Strategy](#strategy) +- [Core Integrations](#core-integrations) + - [Core Mocks](#core-mocks) + - [Strategies for specific Core APIs](#strategies-for-specific-core-apis) + - [HTTP Routes](#http-routes) + - [SavedObjects](#savedobjects) + - [Elasticsearch](#elasticsearch) +- [Plugin Integrations](#plugin-integrations) +- [Plugin Contracts](#plugin-contracts) + +## Strategy + +In general, we recommend three tiers of tests: +- Unit tests: small, fast, exhaustive, make heavy use of mocks for external dependencies +- Integration tests: higher-level tests that verify interactions between systems (eg. HTTP APIs, Elasticsearch API calls, calling other plugin contracts). +- End-to-end tests (e2e): tests that verify user-facing behavior through the browser + +These tiers should roughly follow the traditional ["testing pyramid"](https://martinfowler.com/articles/practical-test-pyramid.html), where there are more exhaustive testing at the unit level, fewer at the integration level, and very few at the functional level. + +## New concerns in the Kibana Platform + +The Kibana Platform introduces new concepts that legacy plugins did not have concern themselves with. Namely: +- **Lifecycles**: plugins now have explicit lifecycle methods that must interop with Core APIs and other plugins. +- **Shared runtime**: plugins now all run in the same process at the same time. On the frontend, this is different behavior than the legacy plugins. Developers should take care not to break other plugins when interacting with their enviornment (Node.js or Browser). +- **Single page application**: Kibana's frontend is now a single-page application where all plugins are running, but only one application is mounted at a time. Plugins need to handle mounting and unmounting, cleanup, and avoid overriding global browser behaviors in this shared space. +- **Dependency management**: plugins must now explicitly declare their dependencies on other plugins, both required and optional. Plugins should ensure to test conditions where a optional dependency is missing. + +Simply porting over existing tests when migrating your plugin to the Kibana Platform will leave blind spots in test coverage. It is highly recommended that plugins add new tests that cover these new concerns. + +## Core Integrations + +### Core Mocks + +When testing a plugin's integration points with Core APIs, it is heavily recommended to utilize the mocks provided in `src/core/server/mocks` and `src/core/public/mocks`. The majority of these mocks are dumb `jest` mocks that mimic the interface of their respective Core APIs, however they do not return realistic return values. + +If the unit under test expects a particular response from a Core API, the test will need to set this return value explicitly. The return values are type checked to match the Core API where possible to ensure that mocks are updated when Core APIs changed. + +#### Example + +```ts +import { elasticsearchServiceMock } from 'src/core/server/mocks'; + +test('my test', async () => { + // Setup mock and faked response + const esClient = elasticsearchServiceMock.createScopedClusterClient(); + esClient.callAsCurrentUser.mockResolvedValue(/** insert ES response here */); + + // Call unit under test with mocked client + const result = await myFunction(esClient); + + // Assert that client was called with expected arguments + expect(esClient.callAsCurrentUser).toHaveBeenCalledWith(/** expected args */); + // Expect that unit under test returns expected value based on client's response + expect(result).toEqual(/** expected return value */) +}); +``` + +### Strategies for specific Core APIs + +#### HTTP Routes + +_How to test route handlers_ + +### Applications + +Kibana Platform applications have less control over the page than legacy applications did. It is important that your app is built to handle it's co-habitance with other plugins in the browser. Applications are mounted and unmounted from the DOM as the user navigates between them, without full-page refreshes, as a single-page application (SPA). + +These long-lived sessions make cleanup more important than before. It's entirely possible a user has a single browsing session open for weeks at a time, without ever doing a full-page refresh. Common things that need to be cleaned up (and tested!) when your application is unmounted: +- Subscriptions and polling (eg. `uiSettings.get$()`) +- Any Core API calls that set state (eg. `core.chrome.setIsVisible`). +- Open connections (eg. a Websocket) + +While applications do get an opportunity to unmount and run cleanup logic, it is also important that you do not _depend_ on this logic to run. The browser tab may get closed without running cleanup logic, so it is not guaranteed to be run. For instance, you should not depend on unmounting logic to run in order to save state to `localStorage` or to the backend. + +#### Example + +By following the [renderApp](./CONVENTIONS.md#applications) convention, you can greatly reduce the amount of logic in your application's mount function. This makes testing your application's actual rendering logic easier. + +```tsx +/** public/plugin.ts */ +class Plugin { + setup(core) { + core.application.register({ + // id, title, etc. + async mount(params) { + const [{ renderApp }, [coreStart, startDeps]] = await Promise.all([ + import('./application'), + core.getStartServices() + ]); + + return renderApp(params, coreStart, startDeps); + } + }) + } +} +``` + +We _could_ still write tests for this logic, but you may find that you're just asserting the same things that would be covered by type-checks. + +
+See example + +```ts +/** public/plugin.test.ts */ +jest.mock('./application', () => ({ renderApp: jest.fn() })); +import { coreMock } from 'src/core/public/mocks'; +import { renderApp: renderAppMock } from './application'; +import { Plugin } from './plugin'; + +describe('Plugin', () => { + it('registers an app', () => { + const coreSetup = coreMock.createSetup(); + new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); + expect(coreSetup.application.register).toHaveBeenCalledWith({ + id: 'myApp', + mount: expect.any(Function) + }); + }); + + // Test the glue code from Plugin -> renderApp + it('application.mount wires up dependencies to renderApp', async () => { + const coreSetup = coreMock.createSetup(); + const [coreStartMock, startDepsMock] = await coreSetup.getStartServices(); + const unmountMock = jest.fn(); + renderAppMock.mockReturnValue(unmountMock); + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + + new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); + // Grab registered mount function + const mount = coreSetup.application.register.mock.calls[0][0].mount; + + const unmount = await mount(params); + expect(renderAppMock).toHaveBeenCalledWith(params, coreStartMock, startDepsMock); + expect(unmount).toBe(unmountMock); + }); +}); +``` + +
+ +The more interesting logic is in `renderApp`: + +```ts +/** public/application.ts */ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { AppMountParams, CoreStart } from 'src/core/public'; +import { AppRoot } from './components/app_root'; + +export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { + // Hide the chrome while this app is mounted for a full screen experience + core.chrome.setIsVisible(false); + + // uiSettings subscription + const uiSettingsClient = core.uiSettings.client; + const pollingSubscription = uiSettingClient.get$('mysetting1').subscribe(async mySetting1 => { + const value = core.http.fetch(/** use `mySetting1` in request **/); + // ... + }); + + // Render app + ReactDOM.render( + , + element + ); + + return () => { + // Unmount UI + ReactDOM.unmountComponentAtNode(element); + // Close any subscriptions + pollingSubscription.unsubscribe(); + // Make chrome visible again + core.chrome.setIsVisible(true); + }; +}; +``` + +In testing `renderApp` you should be verifying that: +1) Your application mounts and unmounts correctly +2) Cleanup logic is completed as expected + +```ts +/** public/application.test.ts */ +import { coreMock } from 'src/core/public/mocks'; +import { renderApp } from './application'; + +describe('renderApp', () => { + it('mounts and unmounts UI', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + + // Verify some expected DOM element is rendered into the element + const unmount = renderApp(params, core, {}); + expect(params.element.querySelector('.some-app-class')).not.toBeUndefined(); + // Verify the element is empty after unmounting + unmount(); + expect(params.element.innerHTML).toEqual(''); + }); + + it('unsubscribes from uiSettings', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + // Create a fake Subject you can use to monitor observers + const settings$ = new Subject(); + core.uiSettings.get$.mockReturnValue(settings$); + + // Verify mounting adds an observer + const unmount = renderApp(params, core, {}); + expect(settings$.observers.length).toBe(1); + // Verify no observers remaining after unmount is called + unmount(); + expect(settings$.observers.length).toBe(0); + }); + + it('resets chrome visibility', () => { + const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const core = coreMock.createStart(); + + // Verify stateful Core API was called on mount + const unmount = renderApp(params, core, {}); + expect(core.chrome.setIsVisible).toHaveBeenCalledWith(false); + core.chrome.setIsVisible.mockClear(); // reset mock + // Verify stateful Core API was called on unmount + unmount(); + expect(core.chrome.setIsVisible).toHaveBeenCalledWith(true); + }) +}); +``` + +#### SavedObjects + +_How to test SO operations_ + +#### Elasticsearch + +_How to test ES clients_ + +## Plugin Integrations + +_How to test against specific plugin APIs (eg. data plugin)_ + +## Plugin Contracts + +_How to test your plugin's exposed API_ + +Guidelines: +- Plugins should never interact with other plugins' REST API directly +- Plugins should interact with other plugins via JavaScript contracts +- Exposed contracts need to be well tested to ensure breaking changes are detected easily diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b2e2161c92cc8..dee47315fc322 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { @@ -25,17 +25,21 @@ import { InternalApplicationStart, ApplicationStart, InternalApplicationSetup, + App, + LegacyApp, } from './types'; import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); const createInternalSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), registerLegacyApp: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); @@ -50,8 +54,7 @@ const createInternalStartContractMock = (): jest.Mocked(); return { - availableApps: new Map(), - availableLegacyApps: new Map(), + applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), getComponent: jest.fn(), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 1132abc11703f..4672a42c9eb06 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -18,8 +18,8 @@ */ import { createElement } from 'react'; -import { Subject } from 'rxjs'; -import { bufferCount, skip, takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { bufferCount, skip, take, takeUntil } from 'rxjs/operators'; import { shallow } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; @@ -29,8 +29,25 @@ import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; - -function mount() {} +import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; + +const createApp = (props: Partial): App => { + return { + id: 'some-id', + title: 'some-title', + mount: () => () => undefined, + ...props, + }; +}; + +const createLegacyApp = (props: Partial): LegacyApp => { + return { + id: 'some-id', + title: 'some-title', + appUrl: '/my-url', + ...props, + }; +}; let setupDeps: MockLifecycle<'setup'>; let startDeps: MockLifecycle<'start'>; @@ -53,9 +70,9 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the id \\"app1\\""` ); @@ -66,37 +83,91 @@ describe('#setup()', () => { await service.start(startDeps); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); + it('allows to register a statusUpdater for the application', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject(app => ({})); + setup.register(pluginId, createApp({ id: 'app1', updater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + const { applications$ } = await service.start(startDeps); + + let applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + + updater$.next(app => ({ + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + })); + + applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + }); + it('throws an error if an App with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1' }))).toThrow(); - register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any); + register(Symbol(), createApp({ id: 'app-next', appRoute: '/app/app3' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app3' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app3\\""` ); - expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app3' }))).not.toThrow(); }); it('throws an error if an App starts with the HTTP base path', () => { const { register } = service.setup(setupDeps); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/test/app2' })) ).toThrowErrorMatchingInlineSnapshot( `"Cannot register an application route that includes HTTP base path"` ); @@ -107,9 +178,11 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app2' } as any); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` + registerLegacyApp(createLegacyApp({ id: 'app2' })); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app2\\""` ); }); @@ -117,22 +190,228 @@ describe('#setup()', () => { const { registerLegacyApp } = service.setup(setupDeps); await service.start(startDeps); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"Applications cannot be registered after \\"setup\\""` - ); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); it('throws an error if a LegacyApp with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app1' } as any); + registerLegacyApp(createLegacyApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1:other' }))).not.toThrow(); + }); + }); + + describe('registerAppStatusUpdater', () => { + it('updates status fields', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.register(pluginId, createApp({ id: 'app2' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'App inaccessible due to reason', + }; + } + return { + tooltip: 'App accessible', + }; + }) + ); + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + tooltip: 'App accessible', + }) + ); + }); + + it(`properly combine with application's updater$`, async () => { + const setup = service.setup(setupDeps); + const pluginId = Symbol('plugin'); + const appStatusUpdater$ = new BehaviorSubject(app => ({ + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + })); + setup.register(pluginId, createApp({ id: 'app1', updater$: appStatusUpdater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.accessible, + tooltip: 'App inaccessible due to reason', + }; + } + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }) + ); + + const { applications$ } = await service.start(startDeps); + const applications = await applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('applies the most restrictive status in case of multiple updaters', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }) + ); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + }) + ); + }); + + it('emits on applications$ when a status updater changes', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + + const statusUpdater = new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }); + setup.registerAppUpdater(statusUpdater); + + const start = await service.start(startDeps); + let latestValue: ReadonlyMap = new Map(); + start.applications$.subscribe(apps => { + latestValue = apps; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }) + ); + + statusUpdater.next(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('also updates legacy apps', async () => { + const setup = service.setup(setupDeps); + + setup.registerLegacyApp(createLegacyApp({ id: 'app1' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: true, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }) + ); }); }); @@ -141,7 +420,8 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - registerMountContext(pluginId, 'test' as any, mount as any); + const mount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, mount); expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); }); }); @@ -171,35 +451,40 @@ describe('#start()', () => { setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'app2' } as any); - - const { availableApps, availableLegacyApps } = await service.start(startDeps); - - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'app2' })); + + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); + + expect(availableApps.size).toEqual(2); + expect([...availableApps.keys()]).toEqual(['app1', 'app2']); + expect(availableApps.get('app1')).toEqual( + expect.objectContaining({ + appRoute: '/app/app1', + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(availableApps.get('app2')).toEqual( + expect.objectContaining({ + appUrl: '/my-url', + id: 'app2', + legacy: true, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); }); it('passes appIds to capabilities', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - register(Symbol(), { id: 'app2', mount } as any); - register(Symbol(), { id: 'app3', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); + register(Symbol(), createApp({ id: 'app2' })); + register(Symbol(), createApp({ id: 'app3' })); await service.start(startDeps); expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ @@ -222,29 +507,15 @@ describe('#start()', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount } as any); - registerLegacyApp({ id: 'legacyApp2' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp2' })); - const { availableApps, availableLegacyApps } = await service.start(startDeps); + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "legacyApp1" => Object { - "id": "legacyApp1", - }, - } - `); + expect([...availableApps.keys()]).toEqual(['app1', 'legacyApp1']); }); describe('getComponent', () => { @@ -290,9 +561,9 @@ describe('#start()', () => { it('creates URL for registered appId', async () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { getUrlForApp } = await service.start(startDeps); @@ -329,7 +600,7 @@ describe('#start()', () => { it('changes the browser history for custom appRoutes', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -343,7 +614,7 @@ describe('#start()', () => { it('appends a path if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -363,7 +634,7 @@ describe('#start()', () => { it('includes state if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -429,7 +700,7 @@ describe('#start()', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + registerLegacyApp(createLegacyApp({ id: 'baseApp:legacyApp1' })); const { navigateToApp } = await service.start(startDeps); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 5b464737ffe07..c69b96274aa95 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -18,8 +18,8 @@ */ import React from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; import { InjectedMetadataSetup } from '../injected_metadata'; @@ -27,18 +27,23 @@ import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; import { ContextSetup, IContextContainer } from '../context'; import { AppRouter } from './ui'; -import { CapabilitiesService, Capabilities } from './capabilities'; +import { Capabilities, CapabilitiesService } from './capabilities'; import { App, + AppBase, AppLeaveHandler, - LegacyApp, AppMount, AppMountDeprecated, AppMounter, - LegacyAppMounter, - Mounter, + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, + AppUpdater, InternalApplicationSetup, InternalApplicationStart, + LegacyApp, + LegacyAppMounter, + Mounter, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; @@ -62,12 +67,13 @@ interface StartDeps { // Mount functions with two arguments are assumed to expect deprecated `context` object. const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => mount.length === 2; -const filterAvailable = (map: Map, capabilities: Capabilities) => - new Map( - [...map].filter( +function filterAvailable(m: Map, capabilities: Capabilities) { + return new Map( + [...m].filter( ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true ) ); +} const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); const getAppUrl = (mounters: Map, appId: string, path: string = '') => @@ -75,17 +81,25 @@ const getAppUrl = (mounters: Map, appId: string, path: string = .replace(/\/{2,}/g, '/') // Remove duplicate slashes .replace(/\/$/, ''); // Remove trailing slash +const allApplicationsFilter = '__ALL__'; + +interface AppUpdaterWrapper { + application: string; + updater: AppUpdater; +} + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps = new Map(); - private readonly legacyApps = new Map(); + private readonly apps = new Map(); private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); private readonly appLeaveHandlers = new Map(); private currentAppId$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); + private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); private registrationClosed = false; private history?: History; @@ -109,8 +123,22 @@ export class ApplicationService { this.navigate = (url, state) => // basePath not needed here because `history` is configured with basename this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); + this.mountContext = context.createContextContainer(); + const registerStatusUpdater = (application: string, updater$: Observable) => { + const updaterId = Symbol(); + const subscription = updater$.subscribe(updater => { + const nextValue = new Map(this.statusUpdaters$.getValue()); + nextValue.set(updaterId, { + application, + updater, + }); + this.statusUpdaters$.next(nextValue); + }); + this.subscriptions.push(subscription); + }; + return { registerMountContext: this.mountContext!.registerContext, register: (plugin, app) => { @@ -145,7 +173,17 @@ export class ApplicationService { this.currentAppId$.next(app.id); return unmount; }; - this.apps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: false, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), @@ -158,15 +196,25 @@ export class ApplicationService { if (this.registrationClosed) { throw new Error('Applications cannot be registered after "setup"'); - } else if (this.legacyApps.has(app.id)) { - throw new Error(`A legacy application is already registered with the id "${app.id}"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); } else if (basename && appRoute!.startsWith(basename)) { throw new Error('Cannot register an application route that includes HTTP base path'); } const appBasePath = basePath.prepend(appRoute); const mount: LegacyAppMounter = () => redirectTo(appBasePath); - this.legacyApps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: true, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute, appBasePath, @@ -174,6 +222,8 @@ export class ApplicationService { unmountBeforeMounting: true, }); }, + registerAppUpdater: (appUpdater$: Observable) => + registerStatusUpdater(allApplicationsFilter, appUpdater$), }; } @@ -190,16 +240,35 @@ export class ApplicationService { http, }); const availableMounters = filterAvailable(this.mounters, capabilities); + const availableApps = filterAvailable(this.apps, capabilities); + + const applications$ = new BehaviorSubject(availableApps); + this.statusUpdaters$ + .pipe( + map(statusUpdaters => { + return new Map( + [...availableApps].map(([id, app]) => [ + id, + updateStatus(app, [...statusUpdaters.values()]), + ]) + ); + }) + ) + .subscribe(apps => applications$.next(apps)); return { - availableApps: filterAvailable(this.apps, capabilities), - availableLegacyApps: filterAvailable(this.legacyApps, capabilities), + applications$, capabilities, currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)), registerMountContext: this.mountContext.registerContext, getUrlForApp: (appId, { path }: { path?: string } = {}) => getAppUrl(availableMounters, appId, path), navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => { + const app = applications$.value.get(appId); + if (app && app.status !== AppStatus.accessible) { + // should probably redirect to the error page instead + throw new Error(`Trying to navigate to an inaccessible application: ${appId}`); + } if (await this.shouldNavigate(overlays)) { this.appLeaveHandlers.delete(this.currentAppId$.value!); this.navigate!(getAppUrl(availableMounters, appId, path), state); @@ -259,6 +328,32 @@ export class ApplicationService { public stop() { this.stop$.next(); this.currentAppId$.complete(); + this.statusUpdaters$.complete(); + this.subscriptions.forEach(sub => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); } } + +const updateStatus = (app: T, statusUpdaters: AppUpdaterWrapper[]): T => { + let changes: Partial = {}; + statusUpdaters.forEach(wrapper => { + if (wrapper.application !== allApplicationsFilter && wrapper.application !== app.id) { + return; + } + const fields = wrapper.updater(app); + if (fields) { + changes = { + ...changes, + ...fields, + // status and navLinkStatus enums are ordered by reversed priority + // if multiple updaters wants to change these fields, we will always follow the priority order. + status: Math.max(changes.status ?? 0, fields.status ?? 0), + navLinkStatus: Math.max(changes.navLinkStatus ?? 0, fields.navLinkStatus ?? 0), + }; + } + }); + return { + ...app, + ...changes, + }; +}; diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 17fec9261accf..e7ea330657648 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -27,6 +27,10 @@ export { AppUnmount, AppMountContext, AppMountParameters, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, ApplicationSetup, ApplicationStart, AppLeaveHandler, diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 4caf236979c37..0d955482d2226 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -34,6 +34,9 @@ import { SavedObjectsStart } from '../saved_objects'; /** @public */ export interface AppBase { + /** + * The unique identifier of the application + */ id: string; /** @@ -41,15 +44,62 @@ export interface AppBase { */ title: string; + /** + * The initial status of the application. + * Defaulting to `accessible` + */ + status?: AppStatus; + + /** + * The initial status of the application's navLink. + * Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` + * See {@link AppNavLinkStatus} + */ + navLinkStatus?: AppNavLinkStatus; + + /** + * An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime. + * + * @example + * + * How to update an application navLink at runtime + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * private appUpdater = new BehaviorSubject(() => ({})); + * + * setup({ application }) { + * application.register({ + * id: 'my-app', + * title: 'My App', + * updater$: this.appUpdater, + * async mount(params) { + * const { renderApp } = await import('./application'); + * return renderApp(params); + * }, + * }); + * } + * + * start() { + * // later, when the navlink needs to be updated + * appUpdater.next(() => { + * navLinkStatus: AppNavLinkStatus.disabled, + * }) + * } + * ``` + */ + updater$?: Observable; + /** * An ordinal used to sort nav links relative to one another for display. */ order?: number; /** - * An observable for a tooltip shown when hovering over app link. + * A tooltip shown when hovering over app link. */ - tooltip$?: Observable; + tooltip?: string; /** * A EUI iconType that will be used for the app's icon. This icon @@ -67,8 +117,76 @@ export interface AppBase { * Custom capabilities defined by the app. */ capabilities?: Partial; + + /** + * Flag to keep track of legacy applications. + * For internal use only. any value will be overridden when registering an App. + * + * @internal + */ + legacy?: boolean; + + /** + * Hide the UI chrome when the application is mounted. Defaults to `false`. + * Takes precedence over chrome service visibility settings. + */ + chromeless?: boolean; } +/** + * Accessibility status of an application. + * + * @public + */ +export enum AppStatus { + /** + * Application is accessible. + */ + accessible = 0, + /** + * Application is not accessible. + */ + inaccessible = 1, +} + +/** + * Status of the application's navLink. + * + * @public + */ +export enum AppNavLinkStatus { + /** + * The application navLink will be `visible` if the application's {@link AppStatus} is set to `accessible` + * and `hidden` if the application status is set to `inaccessible`. + */ + default = 0, + /** + * The application navLink is visible and clickable in the navigation bar. + */ + visible = 1, + /** + * The application navLink is visible but inactive and not clickable in the navigation bar. + */ + disabled = 2, + /** + * The application navLink does not appear in the navigation bar. + */ + hidden = 3, +} + +/** + * Defines the list of fields that can be updated via an {@link AppUpdater}. + * @public + */ +export type AppUpdatableFields = Pick; + +/** + * Updater for applications. + * see {@link ApplicationSetup} + * @public + */ +export type AppUpdater = (app: AppBase) => Partial | undefined; + /** * Extension of {@link AppBase | common app properties} with the mount function. * @public @@ -374,6 +492,35 @@ export interface ApplicationSetup { */ register(app: App): void; + /** + * Register an application updater that can be used to change the {@link AppUpdatableFields} fields + * of all applications at runtime. + * + * This is meant to be used by plugins that needs to updates the whole list of applications. + * To only updates a specific application, use the `updater$` property of the registered application instead. + * + * @example + * + * How to register an application updater that disables some applications: + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * setup({ application }) { + * application.registerAppUpdater( + * new BehaviorSubject(app => { + * if (myPluginApi.shouldDisable(app)) + * return { + * status: AppStatus.inaccessible, + * }; + * }) + * ); + * } + * } + * ``` + */ + registerAppUpdater(appUpdater$: Observable): void; + /** * Register a context provider for application mounting. Will only be available to applications that depend on the * plugin that registered this context. Deprecated, use {@link CoreSetup.getStartServices}. @@ -389,7 +536,7 @@ export interface ApplicationSetup { } /** @internal */ -export interface InternalApplicationSetup { +export interface InternalApplicationSetup extends Pick { /** * Register an mountable application to the system. * @param plugin - opaque ID of the plugin that registers this application @@ -462,16 +609,11 @@ export interface ApplicationStart { export interface InternalApplicationStart extends Pick { /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - */ - availableApps: ReadonlyMap; - /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - * @internal + * Apps available based on the current capabilities. + * Should be used to show navigation links and make routing decisions. + * Applications manually disabled from the client-side using {@link AppUpdater} */ - availableLegacyApps: ReadonlyMap; + applications$: Observable>; /** * Register a context provider for application mounting. Will only be available to applications that depend on the diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index d9c35b20db03b..abd04722a49f2 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -18,7 +18,7 @@ */ import * as Rx from 'rxjs'; -import { toArray } from 'rxjs/operators'; +import { take, toArray } from 'rxjs/operators'; import { shallow } from 'enzyme'; import React from 'react'; @@ -54,7 +54,9 @@ function defaultStartDeps(availableApps?: App[]) { }; if (availableApps) { - deps.application.availableApps = new Map(availableApps.map(app => [app.id, app])); + deps.application.applications$ = new Rx.BehaviorSubject>( + new Map(availableApps.map(app => [app.id, app])) + ); } return deps; @@ -211,13 +213,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, navigateToApp } = startDeps.application; + const { applications$, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); + const availableApps = await applications$.pipe(take(1)).toPromise(); [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 18c0c9870d72f..a674b49a8e134 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 5a45491df28e7..3d9a4bfdb6a56 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -20,34 +20,47 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App, LegacyApp } from '../../application'; +import { BehaviorSubject } from 'rxjs'; -const mockAppService = { - availableApps: new Map( - ([ - { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }, - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }, - ] as App[]).map(app => [app.id, app]) - ), - availableLegacyApps: new Map( - ([ - { id: 'legacyApp1', order: 5, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, - { - id: 'legacyApp2', - order: -5, - title: 'Legacy App 2', - euiIconType: 'canvasApp', - appUrl: '/app2', - }, - { id: 'legacyApp3', order: 15, title: 'Legacy App 3', appUrl: '/app3' }, - ] as LegacyApp[]).map(app => [app.id, app]) - ), -} as any; +const availableApps = new Map([ + ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], + ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], + [ + 'legacyApp1', + { + id: 'legacyApp1', + order: 5, + title: 'Legacy App 1', + icon: 'legacyApp1', + appUrl: '/app1', + legacy: true, + }, + ], + [ + 'legacyApp2', + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + legacy: true, + }, + ], + [ + 'legacyApp3', + { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3', legacy: true }, + ], +]); const mockHttp = { basePath: { @@ -57,10 +70,16 @@ const mockHttp = { describe('NavLinksService', () => { let service: NavLinksService; + let mockAppService: any; let start: ReturnType; beforeEach(() => { service = new NavLinksService(); + mockAppService = { + applications$: new BehaviorSubject>( + availableApps as any + ), + }; start = service.start({ application: mockAppService, http: mockHttp }); }); @@ -183,22 +202,36 @@ describe('NavLinksService', () => { .toPromise() ).toEqual(['legacyApp1']); }); + + it('still removes all other links when availableApps are re-emitted', async () => { + start.showOnly('legacyApp2'); + mockAppService.applications$.next(mockAppService.applications$.value); + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.map(l => l.id)) + ) + .toPromise() + ).toEqual(['legacyApp2']); + }); }); describe('#update()', () => { it('updates the navlinks and returns the updated link', async () => { - expect(start.update('legacyApp1', { hidden: true })).toMatchInlineSnapshot(` - Object { - "appUrl": "/app1", - "baseUrl": "http://localhost/wow/app1", - "hidden": true, - "icon": "legacyApp1", - "id": "legacyApp1", - "legacy": true, - "order": 5, - "title": "Legacy App 1", - } - `); + expect(start.update('legacyApp1', { hidden: true })).toEqual( + expect.objectContaining({ + appUrl: '/app1', + disabled: false, + hidden: true, + icon: 'legacyApp1', + id: 'legacyApp1', + legacy: true, + order: 5, + title: 'Legacy App 1', + }) + ); const hiddenLinkIds = await start .getNavLinks$() .pipe( @@ -212,6 +245,19 @@ describe('NavLinksService', () => { it('returns undefined if link does not exist', () => { expect(start.update('fake', { hidden: true })).toBeUndefined(); }); + + it('keeps the updated link when availableApps are re-emitted', async () => { + start.update('legacyApp1', { hidden: true }); + mockAppService.applications$.next(mockAppService.applications$.value); + const hiddenLinkIds = await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.filter(l => l.hidden).map(l => l.id)) + ) + .toPromise(); + expect(hiddenLinkIds).toEqual(['legacyApp1']); + }); }); describe('#enableForcedAppSwitcherNavigation()', () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 31a729f90cd93..650ef77b6fe42 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -18,11 +18,13 @@ */ import { sortBy } from 'lodash'; -import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { NavLinkWrapper, ChromeNavLinkUpdateableFields, ChromeNavLink } from './nav_link'; + import { InternalApplicationStart } from '../../application'; import { HttpStart } from '../../http'; +import { ChromeNavLink, ChromeNavLinkUpdateableFields, NavLinkWrapper } from './nav_link'; +import { toNavLink } from './to_nav_link'; interface StartDeps { application: InternalApplicationStart; @@ -95,39 +97,38 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } +type LinksUpdater = (navLinks: Map) => Map; + export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { - const appLinks = [...application.availableApps] - .filter(([, app]) => !app.chromeless) - .map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: false, - baseUrl: relativeToAbsolute(http.basePath.prepend(`/app/${appId}`)), - }), - ] as [string, NavLinkWrapper] - ); - - const legacyAppLinks = [...application.availableLegacyApps].map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: true, - baseUrl: relativeToAbsolute(http.basePath.prepend(app.appUrl)), - }), - ] as [string, NavLinkWrapper] + const appLinks$ = application.applications$.pipe( + map(apps => { + return new Map( + [...apps] + .filter(([, app]) => !app.chromeless) + .map(([appId, app]) => [appId, toNavLink(app, http.basePath)]) + ); + }) ); - const navLinks$ = new BehaviorSubject>( - new Map([...legacyAppLinks, ...appLinks]) - ); + // now that availableApps$ is an observable, we need to keep record of all + // manual link modifications to be able to re-apply then after every + // availableApps$ changes. + const linkUpdaters$ = new BehaviorSubject([]); + const navLinks$ = new BehaviorSubject>(new Map()); + + combineLatest([appLinks$, linkUpdaters$]) + .pipe( + map(([appLinks, linkUpdaters]) => { + return linkUpdaters.reduce((links, updater) => updater(links), appLinks); + }) + ) + .subscribe(navlinks => { + navLinks$.next(navlinks); + }); + const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { @@ -153,7 +154,10 @@ export class NavLinksService { return; } - navLinks$.next(new Map([...navLinks$.value.entries()].filter(([linkId]) => linkId === id))); + const updater: LinksUpdater = navLinks => + new Map([...navLinks.entries()].filter(([linkId]) => linkId === id)); + + linkUpdaters$.next([...linkUpdaters$.value, updater]); }, update(id: string, values: ChromeNavLinkUpdateableFields) { @@ -161,17 +165,17 @@ export class NavLinksService { return; } - navLinks$.next( + const updater: LinksUpdater = navLinks => new Map( - [...navLinks$.value.entries()].map(([linkId, link]) => { + [...navLinks.entries()].map(([linkId, link]) => { return [linkId, link.id === id ? link.update(values) : link] as [ string, NavLinkWrapper ]; }) - ) - ); + ); + linkUpdaters$.next([...linkUpdaters$.value, updater]); return this.get(id); }, @@ -196,10 +200,3 @@ function sortNavLinks(navLinks: ReadonlyMap) { 'order' ); } - -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts new file mode 100644 index 0000000000000..23fdabe0f3430 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -0,0 +1,178 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppMount, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { toNavLink } from './to_nav_link'; + +import { httpServiceMock } from '../../mocks'; + +function mount() {} + +const app = (props: Partial = {}): App => ({ + mount: (mount as unknown) as AppMount, + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + appRoute: `/app/some-id`, + legacy: false, + ...props, +}); + +const legacyApp = (props: Partial = {}): LegacyApp => ({ + appUrl: '/my-app-url', + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + legacy: true, + ...props, +}); + +describe('toNavLink', () => { + const basePath = httpServiceMock.createSetupContract({ basePath: '/base-path' }).basePath; + + it('uses the application properties when creating the navLink', () => { + const link = toNavLink( + app({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }), + basePath + ); + expect(link.properties).toEqual( + expect.objectContaining({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }) + ); + }); + + it('flags legacy apps when converting to navLink', () => { + expect(toNavLink(app({}), basePath).properties.legacy).toEqual(false); + expect(toNavLink(legacyApp({}), basePath).properties.legacy).toEqual(true); + }); + + it('handles applications with custom app route', () => { + const link = toNavLink( + app({ + appRoute: '/my-route/my-path', + }), + basePath + ); + expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path'); + }); + + it('uses appUrl when converting legacy applications', () => { + expect( + toNavLink( + legacyApp({ + appUrl: '/my-legacy-app/#foo', + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + baseUrl: 'http://localhost/base-path/my-legacy-app/#foo', + }) + ); + }); + + it('uses the application status when the navLinkStatus is set to default', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + }); + + it('uses the navLinkStatus of the application to set the hidden and disabled properties', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.visible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.hidden, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.disabled, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: true, + hidden: false, + }) + ); + }); +}); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts new file mode 100644 index 0000000000000..18e4b7b26b6ba --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { IBasePath } from '../../http'; +import { NavLinkWrapper } from './nav_link'; + +export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { + const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; + return new NavLinkWrapper({ + ...app, + hidden: useAppStatus + ? app.status === AppStatus.inaccessible + : app.navLinkStatus === AppNavLinkStatus.hidden, + disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, + legacy: isLegacyApp(app), + baseUrl: isLegacyApp(app) + ? relativeToAbsolute(basePath.prepend(app.appUrl)) + : relativeToAbsolute(basePath.prepend(app.appRoute!)), + }); +} + +function relativeToAbsolute(url: string) { + // convert all link urls to absolute urls + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} + +function isLegacyApp(app: App | LegacyApp): app is LegacyApp { + return app.legacy === true; +} diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 75f78ac8b2fa0..0447add491788 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/index.ts b/src/core/public/index.ts index ea704749c6131..5b17eccc37f8b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,6 +94,10 @@ export { AppLeaveAction, AppLeaveDefaultAction, AppLeaveConfirmAction, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, } from './application'; export { diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index a4fdd86de5311..f906aff1759e2 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -81,6 +81,7 @@ export class LegacyPlatformService { ...core, getStartServices: () => this.startDependencies, application: { + ...core.application, register: notSupported(`core.application.register()`), registerMountContext: notSupported(`core.application.registerMountContext()`), }, diff --git a/src/core/public/notifications/toasts/global_toast_list.test.tsx b/src/core/public/notifications/toasts/global_toast_list.test.tsx index 61d73ac233188..dc2a9dabe791e 100644 --- a/src/core/public/notifications/toasts/global_toast_list.test.tsx +++ b/src/core/public/notifications/toasts/global_toast_list.test.tsx @@ -57,9 +57,9 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { it('passes latest value from toasts$ to ', () => { const el = shallow( render({ - toasts$: Rx.from([[], [{ id: 1 }], [{ id: 1 }, { id: 2 }]]) as any, + toasts$: Rx.from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, }) ); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: 1 }, { id: 2 }]); + expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); }); diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 848f46605d4de..f146c2452868b 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -96,6 +96,7 @@ export function createPluginSetupContext< return { application: { register: app => deps.application.register(plugin.opaqueId, app), + registerAppUpdater: statusUpdater$ => deps.application.registerAppUpdater(statusUpdater$), registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c76d6191de8a3..aef689162f45a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -26,13 +26,18 @@ export interface App extends AppBase { // @public (undocumented) export interface AppBase { capabilities?: Partial; + chromeless?: boolean; euiIconType?: string; icon?: string; - // (undocumented) id: string; + // @internal + legacy?: boolean; + navLinkStatus?: AppNavLinkStatus; order?: number; + status?: AppStatus; title: string; - tooltip$?: Observable; + tooltip?: string; + updater$?: Observable; } // @public @@ -74,6 +79,7 @@ export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction // @public (undocumented) export interface ApplicationSetup { register(app: App): void; + registerAppUpdater(appUpdater$: Observable): void; // @deprecated registerMountContext(contextName: T, provider: IContextProvider): void; } @@ -123,9 +129,29 @@ export interface AppMountParameters { onAppLeave: (handler: AppLeaveHandler) => void; } +// @public +export enum AppNavLinkStatus { + default = 0, + disabled = 2, + hidden = 3, + visible = 1 +} + +// @public +export enum AppStatus { + accessible = 0, + inaccessible = 1 +} + // @public export type AppUnmount = () => void; +// @public +export type AppUpdatableFields = Pick; + +// @public +export type AppUpdater = (app: AppBase) => Partial | undefined; + // @public export interface Capabilities { [key: string]: Record>; diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index 7851522ec899f..b40dbdc1b6651 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -208,35 +208,4 @@ describe('core deprecations', () => { ).toEqual([`worker-src blob:`]); }); }); - - describe('elasticsearchUsernameDeprecation', () => { - it('logs a warning if elasticsearch.username is set to "elastic"', () => { - const { messages } = applyCoreDeprecations({ - elasticsearch: { - username: 'elastic', - }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "Setting elasticsearch.username to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", - ] - `); - }); - - it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { - const { messages } = applyCoreDeprecations({ - elasticsearch: { - username: 'otheruser', - }, - }); - expect(messages).toHaveLength(0); - }); - - it('does not log a warning if elasticsearch.username is unset', () => { - const { messages } = applyCoreDeprecations({ - elasticsearch: {}, - }); - expect(messages).toHaveLength(0); - }); - }); }); diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index e3b66414ee163..36fe95e05cb53 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -91,16 +91,6 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, log) => { return settings; }; -const elasticsearchUsernameDeprecation: ConfigDeprecation = (settings, _fromPath, log) => { - const username: string | undefined = get(settings, 'elasticsearch.username'); - if (username === 'elastic') { - log( - `Setting elasticsearch.username to "elastic" is deprecated. You should use the "kibana" user instead.` - ); - } - return settings; -}; - export const coreDeprecationProvider: ConfigDeprecationProvider = ({ unusedFromRoot, renameFromRoot, @@ -120,5 +110,4 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ dataPathDeprecation, rewriteBasePathDeprecation, cspRulesDeprecation, - elasticsearchUsernameDeprecation, ]; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index c0db7369b4b99..1b4fc5eafec76 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -23,19 +23,32 @@ import { mockReadPkcs12Truststore, } from './elasticsearch_config.test.mocks'; -import { ElasticsearchConfig, config, ElasticsearchConfigType } from './elasticsearch_config'; -import { loggingServiceMock } from '../mocks'; -import { Logger } from '../logging'; - -const createElasticsearchConfig = (rawConfig: ElasticsearchConfigType, log?: Logger) => { - if (!log) { - log = loggingServiceMock.create().get('config'); - } - return new ElasticsearchConfig(rawConfig, log); +import { ElasticsearchConfig, config } from './elasticsearch_config'; +import { applyDeprecations, configDeprecationFactory } from '../config/deprecation'; + +const CONFIG_PATH = 'elasticsearch'; + +const applyElasticsearchDeprecations = (settings: Record = {}) => { + const deprecations = config.deprecations!(configDeprecationFactory); + const deprecationMessages: string[] = []; + const _config: any = {}; + _config[CONFIG_PATH] = settings; + const migrated = applyDeprecations( + _config, + deprecations.map(deprecation => ({ + deprecation, + path: CONFIG_PATH, + })), + msg => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; }; test('set correct defaults', () => { - const configValue = createElasticsearchConfig(config.schema.validate({})); + const configValue = new ElasticsearchConfig(config.schema.validate({})); expect(configValue).toMatchInlineSnapshot(` ElasticsearchConfig { "apiVersion": "master", @@ -70,17 +83,17 @@ test('set correct defaults', () => { }); test('#hosts accepts both string and array of strings', () => { - let configValue = createElasticsearchConfig( + let configValue = new ElasticsearchConfig( config.schema.validate({ hosts: 'http://some.host:1234' }) ); expect(configValue.hosts).toEqual(['http://some.host:1234']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ hosts: ['http://some.host:1234'] }) ); expect(configValue.hosts).toEqual(['http://some.host:1234']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ hosts: ['http://some.host:1234', 'https://some.another.host'], }) @@ -89,17 +102,17 @@ test('#hosts accepts both string and array of strings', () => { }); test('#requestHeadersWhitelist accepts both string and array of strings', () => { - let configValue = createElasticsearchConfig( + let configValue = new ElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: 'token' }) ); expect(configValue.requestHeadersWhitelist).toEqual(['token']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: ['token'] }) ); expect(configValue.requestHeadersWhitelist).toEqual(['token']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: ['token', 'X-Forwarded-Proto'], }) @@ -122,7 +135,7 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.keystore.path is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) ); expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); @@ -130,7 +143,7 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.truststore.path is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { truststore: { path: 'some-path' } } }) ); expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); @@ -138,21 +151,21 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.certificateAuthorities is specified', () => { - let configValue = createElasticsearchConfig( + let configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificateAuthorities: 'some-path' } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); mockReadFileSync.mockClear(); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificateAuthorities: ['some-path'] } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); mockReadFileSync.mockClear(); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificateAuthorities: ['some-path', 'another-path'] }, }) @@ -165,7 +178,7 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.keystore.path, ssl.truststore.path, and ssl.certificateAuthorities are specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { keystore: { path: 'some-path' }, @@ -185,7 +198,7 @@ describe('reads files', () => { }); it('reads a private key and certificate when ssl.keystore.path is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) ); expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); @@ -194,7 +207,7 @@ describe('reads files', () => { }); it('reads a private key when ssl.key is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { key: 'some-path' } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); @@ -202,7 +215,7 @@ describe('reads files', () => { }); it('reads a certificate when ssl.certificate is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificate: 'some-path' } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); @@ -225,8 +238,8 @@ describe('throws when config is invalid', () => { it('throws if key is invalid', () => { const value = { ssl: { key: '/invalid/key' } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/key'"` ); @@ -234,8 +247,8 @@ describe('throws when config is invalid', () => { it('throws if certificate is invalid', () => { const value = { ssl: { certificate: '/invalid/cert' } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/cert'"` ); @@ -243,34 +256,40 @@ describe('throws when config is invalid', () => { it('throws if certificateAuthorities is invalid', () => { const value = { ssl: { certificateAuthorities: '/invalid/ca' } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot(`"ENOENT: no such file or directory, open '/invalid/ca'"`); }); it('throws if keystore path is invalid', () => { const value = { ssl: { keystore: { path: '/invalid/keystore' } } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/keystore'"` ); }); - it('throws if keystore does not contain a key or certificate', () => { + it('throws if keystore does not contain a key', () => { mockReadPkcs12Keystore.mockReturnValueOnce({}); const value = { ssl: { keystore: { path: 'some-path' } } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) - ).toThrowErrorMatchingInlineSnapshot( - `"Did not find key or certificate in Elasticsearch keystore."` - ); + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"Did not find key in Elasticsearch keystore."`); + }); + + it('throws if keystore does not contain a certificate', () => { + mockReadPkcs12Keystore.mockReturnValueOnce({ key: 'foo' }); + const value = { ssl: { keystore: { path: 'some-path' } } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"Did not find certificate in Elasticsearch keystore."`); }); it('throws if truststore path is invalid', () => { const value = { ssl: { keystore: { path: '/invalid/truststore' } } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/truststore'"` ); @@ -291,31 +310,47 @@ describe('throws when config is invalid', () => { }); }); -describe('logs warnings', () => { - let logger: ReturnType; - let log: Logger; +describe('deprecations', () => { + it('logs a warning if elasticsearch.username is set to "elastic"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'elastic' }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.username] to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", + ] + `); + }); - beforeAll(() => { - mockReadFileSync.mockResolvedValue('foo'); + it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'otheruser' }); + expect(messages).toHaveLength(0); }); - beforeEach(() => { - logger = loggingServiceMock.create(); - log = logger.get('config'); + it('does not log a warning if elasticsearch.username is unset', () => { + const { messages } = applyElasticsearchDeprecations({}); + expect(messages).toHaveLength(0); }); - it('warns if ssl.key is set and ssl.certificate is not', () => { - createElasticsearchConfig(config.schema.validate({ ssl: { key: 'some-path' } }), log); - expect(loggingServiceMock.collect(logger).warn[0][0]).toMatchInlineSnapshot( - `"Detected a key without a certificate; mutual TLS authentication is disabled."` - ); + it('logs a warning if ssl.key is set and ssl.certificate is not', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { key: '' } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.ssl.key] without [${CONFIG_PATH}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + ] + `); }); - it('warns if ssl.certificate is set and ssl.key is not', () => { - createElasticsearchConfig(config.schema.validate({ ssl: { certificate: 'some-path' } }), log); - expect(loggingServiceMock.collect(logger).warn[0][0]).toMatchInlineSnapshot( - `"Detected a certificate without a key; mutual TLS authentication is disabled."` - ); + it('logs a warning if ssl.certificate is set and ssl.key is not', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { certificate: '' } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.ssl.certificate] without [${CONFIG_PATH}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + ] + `); + }); + + it('does not log a warning if both ssl.key and ssl.certificate are set', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { key: '', certificate: '' } }); + expect(messages).toEqual([]); }); }); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 815005f65c6e7..5f06c51a53d53 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -20,92 +20,120 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { Duration } from 'moment'; import { readFileSync } from 'fs'; +import { ConfigDeprecationProvider } from 'src/core/server'; import { readPkcs12Keystore, readPkcs12Truststore } from '../../utils'; -import { Logger } from '../logging'; +import { ServiceConfigDescriptor } from '../internal_types'; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); export const DEFAULT_API_VERSION = 'master'; -export type ElasticsearchConfigType = TypeOf; +export type ElasticsearchConfigType = TypeOf; type SslConfigSchema = ElasticsearchConfigType['ssl']; -export const config = { - path: 'elasticsearch', - schema: schema.object({ - sniffOnStart: schema.boolean({ defaultValue: false }), - sniffInterval: schema.oneOf([schema.duration(), schema.literal(false)], { - defaultValue: false, - }), - sniffOnConnectionFault: schema.boolean({ defaultValue: false }), - hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { - defaultValue: 'http://localhost:9200', - }), - preserveHost: schema.boolean({ defaultValue: true }), - username: schema.maybe( - schema.conditional( - schema.contextRef('dist'), - false, - schema.string({ - validate: rawConfig => { - if (rawConfig === 'elastic') { - return ( - 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + - 'privilege-related issues. You should use the "kibana" user instead.' - ); - } - }, - }), - schema.string() - ) - ), - password: schema.maybe(schema.string()), - requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { - defaultValue: ['authorization'], - }), - customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), - shardTimeout: schema.duration({ defaultValue: '30s' }), - requestTimeout: schema.duration({ defaultValue: '30s' }), - pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), - startupTimeout: schema.duration({ defaultValue: '5s' }), - logQueries: schema.boolean({ defaultValue: false }), - ssl: schema.object( - { - verificationMode: schema.oneOf( - [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], - { defaultValue: 'full' } - ), - certificateAuthorities: schema.maybe( - schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) - ), - certificate: schema.maybe(schema.string()), - key: schema.maybe(schema.string()), - keyPassphrase: schema.maybe(schema.string()), - keystore: schema.object({ - path: schema.maybe(schema.string()), - password: schema.maybe(schema.string()), - }), - truststore: schema.object({ - path: schema.maybe(schema.string()), - password: schema.maybe(schema.string()), - }), - alwaysPresentCertificate: schema.boolean({ defaultValue: false }), - }, - { +const configSchema = schema.object({ + sniffOnStart: schema.boolean({ defaultValue: false }), + sniffInterval: schema.oneOf([schema.duration(), schema.literal(false)], { + defaultValue: false, + }), + sniffOnConnectionFault: schema.boolean({ defaultValue: false }), + hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { + defaultValue: 'http://localhost:9200', + }), + preserveHost: schema.boolean({ defaultValue: true }), + username: schema.maybe( + schema.conditional( + schema.contextRef('dist'), + false, + schema.string({ validate: rawConfig => { - if (rawConfig.key && rawConfig.keystore.path) { - return 'cannot use [key] when [keystore.path] is specified'; - } - if (rawConfig.certificate && rawConfig.keystore.path) { - return 'cannot use [certificate] when [keystore.path] is specified'; + if (rawConfig === 'elastic') { + return ( + 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + + 'privilege-related issues. You should use the "kibana" user instead.' + ); } }, - } - ), - apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), - healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), - ignoreVersionMismatch: schema.boolean({ defaultValue: false }), + }), + schema.string() + ) + ), + password: schema.maybe(schema.string()), + requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { + defaultValue: ['authorization'], }), + customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), + shardTimeout: schema.duration({ defaultValue: '30s' }), + requestTimeout: schema.duration({ defaultValue: '30s' }), + pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), + startupTimeout: schema.duration({ defaultValue: '5s' }), + logQueries: schema.boolean({ defaultValue: false }), + ssl: schema.object( + { + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + certificate: schema.maybe(schema.string()), + key: schema.maybe(schema.string()), + keyPassphrase: schema.maybe(schema.string()), + keystore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + truststore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + alwaysPresentCertificate: schema.boolean({ defaultValue: false }), + }, + { + validate: rawConfig => { + if (rawConfig.key && rawConfig.keystore.path) { + return 'cannot use [key] when [keystore.path] is specified'; + } + if (rawConfig.certificate && rawConfig.keystore.path) { + return 'cannot use [certificate] when [keystore.path] is specified'; + } + }, + } + ), + apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), + healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), + ignoreVersionMismatch: schema.boolean({ defaultValue: false }), +}); + +const deprecations: ConfigDeprecationProvider = () => [ + (settings, fromPath, log) => { + const es = settings[fromPath]; + if (!es) { + return settings; + } + if (es.username === 'elastic') { + log( + `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana" user instead.` + ); + } + if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) { + log( + `Setting [${fromPath}.ssl.key] without [${fromPath}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` + ); + } else if (es.ssl?.certificate !== undefined && es.ssl?.key === undefined) { + log( + `Setting [${fromPath}.ssl.certificate] without [${fromPath}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` + ); + } + return settings; + }, +]; + +export const config: ServiceConfigDescriptor = { + path: 'elasticsearch', + schema: configSchema, + deprecations, }; export class ElasticsearchConfig { @@ -205,7 +233,7 @@ export class ElasticsearchConfig { */ public readonly customHeaders: ElasticsearchConfigType['customHeaders']; - constructor(rawConfig: ElasticsearchConfigType, log: Logger) { + constructor(rawConfig: ElasticsearchConfigType) { this.ignoreVersionMismatch = rawConfig.ignoreVersionMismatch; this.apiVersion = rawConfig.apiVersion; this.logQueries = rawConfig.logQueries; @@ -227,12 +255,6 @@ export class ElasticsearchConfig { const { alwaysPresentCertificate, verificationMode } = rawConfig.ssl; const { key, keyPassphrase, certificate, certificateAuthorities } = readKeyAndCerts(rawConfig); - if (key && !certificate) { - log.warn(`Detected a key without a certificate; mutual TLS authentication is disabled.`); - } else if (certificate && !key) { - log.warn(`Detected a certificate without a key; mutual TLS authentication is disabled.`); - } - this.ssl = { alwaysPresentCertificate, key, @@ -261,8 +283,10 @@ const readKeyAndCerts = (rawConfig: ElasticsearchConfigType) => { rawConfig.ssl.keystore.path, rawConfig.ssl.keystore.password ); - if (!keystore.key && !keystore.cert) { - throw new Error(`Did not find key or certificate in Elasticsearch keystore.`); + if (!keystore.key) { + throw new Error(`Did not find key in Elasticsearch keystore.`); + } else if (!keystore.cert) { + throw new Error(`Did not find certificate in Elasticsearch keystore.`); } key = keystore.key; certificate = keystore.cert; diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index de32e7f6cf225..db3fda3a504ab 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -52,7 +52,7 @@ export class ElasticsearchService implements CoreService('elasticsearch') - .pipe(map(rawConfig => new ElasticsearchConfig(rawConfig, coreContext.logger.get('config')))); + .pipe(map(rawConfig => new ElasticsearchConfig(rawConfig))); } public async setup(deps: SetupDeps): Promise { diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index c3b9b20d84865..a1523781010d4 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -142,6 +142,61 @@ describe('Handler', () => { statusCode: 400, }); }); + + it('accept to receive an array payload', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + let body: any = null; + router.post( + { + path: '/', + validate: { + body: schema.arrayOf(schema.object({ foo: schema.string() })), + }, + }, + (context, req, res) => { + body = req.body; + return res.ok({ body: 'ok' }); + } + ); + await server.start(); + + await supertest(innerServer.listener) + .post('/') + .send([{ foo: 'bar' }, { foo: 'dolly' }]) + .expect(200); + + expect(body).toEqual([{ foo: 'bar' }, { foo: 'dolly' }]); + }); + + it('accept to receive a json primitive payload', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + let body: any = null; + router.post( + { + path: '/', + validate: { + body: schema.number(), + }, + }, + (context, req, res) => { + body = req.body; + return res.ok({ body: 'ok' }); + } + ); + await server.start(); + + await supertest(innerServer.listener) + .post('/') + .type('json') + .send('12') + .expect(200); + + expect(body).toEqual(12); + }); }); describe('handleLegacyErrors', () => { diff --git a/src/core/server/http/router/validator/validator.test.ts b/src/core/server/http/router/validator/validator.test.ts index 729eb1b60c10a..e972e2075e705 100644 --- a/src/core/server/http/router/validator/validator.test.ts +++ b/src/core/server/http/router/validator/validator.test.ts @@ -132,4 +132,62 @@ describe('Router validator', () => { 'The validation rule provided in the handler is not valid' ); }); + + it('should validate and infer type when data is an array', () => { + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.string()), + }).getBody(['foo', 'bar']) + ).toStrictEqual(['foo', 'bar']); + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody([1, 2, 3]) + ).toStrictEqual([1, 2, 3]); + expect( + RouteValidator.from({ + body: schema.arrayOf(schema.object({ foo: schema.string() })), + }).getBody([{ foo: 'bar' }, { foo: 'dolly' }]) + ).toStrictEqual([{ foo: 'bar' }, { foo: 'dolly' }]); + + expect(() => + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody(['foo', 'bar', 'dolly']) + ).toThrowError('[0]: expected value of type [number] but got [string]'); + expect(() => + RouteValidator.from({ + body: schema.arrayOf(schema.number()), + }).getBody({ foo: 'bar' }) + ).toThrowError('expected value of type [array] but got [Object]'); + }); + + it('should validate and infer type when data is a primitive', () => { + expect( + RouteValidator.from({ + body: schema.string(), + }).getBody('foobar') + ).toStrictEqual('foobar'); + expect( + RouteValidator.from({ + body: schema.number(), + }).getBody(42) + ).toStrictEqual(42); + expect( + RouteValidator.from({ + body: schema.boolean(), + }).getBody(true) + ).toStrictEqual(true); + + expect(() => + RouteValidator.from({ + body: schema.string(), + }).getBody({ foo: 'bar' }) + ).toThrowError('expected value of type [string] but got [Object]'); + expect(() => + RouteValidator.from({ + body: schema.number(), + }).getBody('foobar') + ).toThrowError('expected value of type [number] but got [string]'); + }); }); diff --git a/src/core/server/http/router/validator/validator.ts b/src/core/server/http/router/validator/validator.ts index 65c0a934e6ef0..97dd2bc894f81 100644 --- a/src/core/server/http/router/validator/validator.ts +++ b/src/core/server/http/router/validator/validator.ts @@ -274,7 +274,7 @@ export class RouteValidator

{ // if options.body.output === 'stream' return schema.stream(); } else { - return schema.maybe(schema.nullable(schema.object({}, { allowUnknowns: true }))); + return schema.maybe(schema.nullable(schema.any({}))); } } } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c4f3bf6caf5bd..bf7dc14c73265 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -450,11 +450,11 @@ export interface AuthToolkit { export class BasePath { // @internal constructor(serverBasePath?: string); - get: (request: KibanaRequest | LegacyRequest) => string; + get: (request: LegacyRequest | KibanaRequest) => string; prepend: (path: string) => string; remove: (path: string) => string; readonly serverBasePath: string; - set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; + set: (request: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void; } // Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/server.ts b/src/core/server/server.ts index eced24b84908c..611842e8a7de0 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -256,6 +256,10 @@ export class Server { ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); + this.configService.addDeprecationProvider( + elasticsearchConfig.path, + elasticsearchConfig.deprecations! + ); this.configService.addDeprecationProvider( uiSettingsConfig.path, uiSettingsConfig.deprecations! diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index abf025524522b..78ac99567d10e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -671,7 +671,9 @@ function discoverController( $scope.$watch('state.query', (newQuery, oldQuery) => { if (!_.isEqual(newQuery, oldQuery)) { const query = migrateLegacyQuery(newQuery); - $scope.updateQueryAndFetch({ query }); + if (!_.isEqual(query, newQuery)) { + $scope.updateQueryAndFetch({ query }); + } } }); @@ -817,6 +819,7 @@ function discoverController( title: i18n.translate('kbn.discover.errorLoadingData', { defaultMessage: 'Error loading data', }), + toastMessage: error.shortMessage, }); } }); diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx index 53a7b1caef2a4..c70b6561c3101 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_options.tsx @@ -36,7 +36,7 @@ import { MarkdownVisParams } from './types'; function MarkdownOptions({ stateParams, setValue }: VisOptionsProps) { const onMarkdownUpdate = useCallback( (value: MarkdownVisParams['markdown']) => setValue('markdown', value), - [] + [setValue] ); return ( diff --git a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js index 3844f809a5257..83d7ca4084a20 100644 --- a/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js +++ b/src/legacy/core_plugins/vis_type_table/public/agg_table/agg_table.js @@ -74,7 +74,11 @@ export function KbnAggTable(config, RecursionHelper) { // escape each cell in each row const csvRows = rows.map(function(row) { return Object.entries(row).map(([k, v]) => { - return escape(formatted ? columns.find(c => c.id === k).formatter.convert(v) : v); + const column = columns.find(c => c.id === k); + if (formatted && column) { + return escape(column.formatter.convert(v)); + } + return escape(v); }); }); @@ -110,12 +114,16 @@ export function KbnAggTable(config, RecursionHelper) { if (typeof $scope.dimensions === 'undefined') return; - const { buckets, metrics } = $scope.dimensions; + const { buckets, metrics, splitColumn } = $scope.dimensions; $scope.formattedColumns = table.columns .map(function(col, i) { const isBucket = buckets.find(bucket => bucket.accessor === i); - const dimension = isBucket || metrics.find(metric => metric.accessor === i); + const isSplitColumn = splitColumn + ? splitColumn.find(splitColumn => splitColumn.accessor === i) + : undefined; + const dimension = + isBucket || isSplitColumn || metrics.find(metric => metric.accessor === i); if (!dimension) return; diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs index d8a55935b705a..85b6de26b9516 100644 --- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs +++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs @@ -14,6 +14,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { window.onload = function () { var files = [ '{{dllBundlePath}}/vendors.bundle.dll.js', + '{{regularBundlePath}}/kbn-ui-shared-deps/{{sharedDepsFilename}}', '{{regularBundlePath}}/commons.bundle.js', '{{regularBundlePath}}/{{appId}}.bundle.js' ]; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 0b266b8b62726..a935270d23fce 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -21,6 +21,7 @@ import { createHash } from 'crypto'; import Boom from 'boom'; import { resolve } from 'path'; import { i18n } from '@kbn/i18n'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { AppBootstrap } from './bootstrap'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; @@ -41,18 +42,10 @@ export function uiRenderMixin(kbnServer, server, config) { // render all views from ./views server.setupViews(resolve(__dirname, 'views')); - server.exposeStaticDir( - '/node_modules/@elastic/eui/dist/{path*}', - fromRoot('node_modules/@elastic/eui/dist') - ); server.exposeStaticDir( '/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist') ); - server.exposeStaticDir( - '/node_modules/@elastic/charts/dist/{path*}', - fromRoot('node_modules/@elastic/charts/dist') - ); const translationsCache = { translations: null, hash: null }; server.route({ @@ -114,14 +107,12 @@ export function uiRenderMixin(kbnServer, server, config) { `${dllBundlePath}/vendors.style.dll.css`, ...(darkMode ? [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`, ] : [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`, ]), `${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`, `${regularBundlePath}/commons.style.css`, @@ -142,6 +133,7 @@ export function uiRenderMixin(kbnServer, server, config) { regularBundlePath, dllBundlePath, styleSheetPaths, + sharedDepsFilename: UiSharedDeps.distFilename, }, }); diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index 9a21a4b1d5439..efff7f0aa2b46 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -19,6 +19,7 @@ import { writeFile } from 'fs'; import os from 'os'; + import Boom from 'boom'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; @@ -26,10 +27,10 @@ import webpack from 'webpack'; import Stats from 'webpack/lib/Stats'; import * as threadLoader from 'thread-loader'; import webpackMerge from 'webpack-merge'; -import { DynamicDllPlugin } from './dynamic_dll_plugin'; import WrapperPlugin from 'wrapper-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; -import { defaults } from 'lodash'; +import { DynamicDllPlugin } from './dynamic_dll_plugin'; import { IS_KIBANA_DISTRIBUTABLE } from '../legacy/utils'; import { fromRoot } from '../core/server/utils'; @@ -403,6 +404,10 @@ export default class BaseOptimizer { // and not for the webpack compilations performance itself hints: false, }, + + externals: { + ...UiSharedDeps.externals, + }, }; // when running from the distributable define an environment variable we can use @@ -417,17 +422,6 @@ export default class BaseOptimizer { ], }; - // We need to add react-addons (and a few other bits) for enzyme to work. - // https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md - const supportEnzymeConfig = { - externals: { - mocha: 'mocha', - 'react/lib/ExecutionEnvironment': true, - 'react/addons': true, - 'react/lib/ReactContext': true, - }, - }; - const watchingConfig = { plugins: [ new webpack.WatchIgnorePlugin([ @@ -482,9 +476,7 @@ export default class BaseOptimizer { IS_CODE_COVERAGE ? coverageConfig : {}, commonConfig, IS_KIBANA_DISTRIBUTABLE ? isDistributableConfig : {}, - this.uiBundles.isDevMode() - ? webpackMerge(watchingConfig, supportEnzymeConfig) - : productionConfig + this.uiBundles.isDevMode() ? watchingConfig : productionConfig ) ); } @@ -515,22 +507,19 @@ export default class BaseOptimizer { } failedStatsToError(stats) { - const details = stats.toString( - defaults( - { colors: true, warningsFilter: STATS_WARNINGS_FILTER }, - Stats.presetToOptions('minimal') - ) - ); + const details = stats.toString({ + ...Stats.presetToOptions('minimal'), + colors: true, + warningsFilter: STATS_WARNINGS_FILTER, + }); return Boom.internal( `Optimizations failure.\n${details.split('\n').join('\n ')}\n`, - stats.toJson( - defaults({ - warningsFilter: STATS_WARNINGS_FILTER, - ...Stats.presetToOptions('detailed'), - maxModules: 1000, - }) - ) + stats.toJson({ + warningsFilter: STATS_WARNINGS_FILTER, + ...Stats.presetToOptions('detailed'), + maxModules: 1000, + }) ); } diff --git a/src/optimize/bundles_route/bundles_route.js b/src/optimize/bundles_route/bundles_route.js index d3c08fae92264..f0261d44e0347 100644 --- a/src/optimize/bundles_route/bundles_route.js +++ b/src/optimize/bundles_route/bundles_route.js @@ -19,6 +19,7 @@ import { isAbsolute, extname } from 'path'; import LruCache from 'lru-cache'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { createDynamicAssetResponse } from './dynamic_asset_response'; /** @@ -66,6 +67,12 @@ export function createBundlesRoute({ } return [ + buildRouteForBundles( + `${basePublicPath}/bundles/kbn-ui-shared-deps/`, + '/bundles/kbn-ui-shared-deps/', + UiSharedDeps.distDir, + fileHashCache + ), buildRouteForBundles( `${basePublicPath}/bundles/`, '/bundles/', diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js index 2a3d3dd659c67..ecf5def5aa6ca 100644 --- a/src/optimize/dynamic_dll_plugin/dll_config_model.js +++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js @@ -23,6 +23,7 @@ import webpack from 'webpack'; import webpackMerge from 'webpack-merge'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; function generateDLL(config) { const { @@ -145,6 +146,9 @@ function generateDLL(config) { // and not for the webpack compilations performance itself hints: false, }, + externals: { + ...UiSharedDeps.externals, + }, }; } diff --git a/src/optimize/watch/watch_cache.ts b/src/optimize/watch/watch_cache.ts index ab11a8c5d2f11..15957210b3d43 100644 --- a/src/optimize/watch/watch_cache.ts +++ b/src/optimize/watch/watch_cache.ts @@ -18,17 +18,18 @@ */ import { createHash } from 'crypto'; -import { readFile, writeFile } from 'fs'; +import { readFile, writeFile, readdir, unlink, rmdir } from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; - +import path from 'path'; import del from 'del'; -import deleteEmpty from 'delete-empty'; -import globby from 'globby'; import normalizePosixPath from 'normalize-path'; const readAsync = promisify(readFile); const writeAsync = promisify(writeFile); +const readdirAsync = promisify(readdir); +const unlinkAsync = promisify(unlink); +const rmdirAsync = promisify(rmdir); interface Params { logWithMetadata: (tags: string[], message: string, metadata?: { [key: string]: any }) => void; @@ -95,11 +96,7 @@ export class WatchCache { await del(this.statePath, { force: true }); // delete everything in optimize/.cache directory - await del(await globby([normalizePosixPath(this.cachePath)], { dot: true })); - - // delete some empty folder that could be left - // from the previous cache path reset action - await deleteEmpty(this.cachePath); + await recursiveDelete(normalizePosixPath(this.cachePath)); // delete dlls await del(this.dllsPath); @@ -167,3 +164,28 @@ export class WatchCache { } } } + +/** + * Recursively deletes a folder. This is a workaround for a bug in `del` where + * very large folders (with 84K+ files) cause a stack overflow. + */ +async function recursiveDelete(directory: string) { + const entries = await readdirAsync(directory, { withFileTypes: true }); + await Promise.all( + entries.map(entry => { + const absolutePath = path.join(directory, entry.name); + const result = entry.isDirectory() + ? recursiveDelete(absolutePath) + : unlinkAsync(absolutePath); + + // Ignore errors, if the file or directory doesn't exist. + return result.catch(e => { + if (e.code !== 'ENOENT') { + throw e; + } + }); + }) + ); + + return rmdirAsync(directory); +} diff --git a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts index 7c90119fcc1bc..0d5cd6ea17f16 100644 --- a/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts +++ b/src/plugins/data/common/es_query/kuery/kuery_syntax_error.ts @@ -41,7 +41,7 @@ const grammarRuleTranslations: Record = { interface KQLSyntaxErrorData extends Error { found: string; - expected: KQLSyntaxErrorExpected[]; + expected: KQLSyntaxErrorExpected[] | null; location: any; } @@ -53,19 +53,22 @@ export class KQLSyntaxError extends Error { shortMessage: string; constructor(error: KQLSyntaxErrorData, expression: any) { - const translatedExpectations = error.expected.map(expected => { - return grammarRuleTranslations[expected.description] || expected.description; - }); + let message = error.message; + if (error.expected) { + const translatedExpectations = error.expected.map(expected => { + return grammarRuleTranslations[expected.description] || expected.description; + }); - const translatedExpectationText = translatedExpectations.join(', '); + const translatedExpectationText = translatedExpectations.join(', '); - const message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', { - defaultMessage: 'Expected {expectedList} but {foundInput} found.', - values: { - expectedList: translatedExpectationText, - foundInput: error.found ? `"${error.found}"` : endOfInputText, - }, - }); + message = i18n.translate('data.common.esQuery.kql.errors.syntaxError', { + defaultMessage: 'Expected {expectedList} but {foundInput} found.', + values: { + expectedList: translatedExpectationText, + foundInput: error.found ? `"${error.found}"` : endOfInputText, + }, + }); + } const fullMessage = [message, expression, repeat('-', error.location.start.offset) + '^'].join( '\n' diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 10b7dd2b4da44..cfe89f16e99dd 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -25,4 +25,5 @@ export * from './overlays'; export * from './ui_settings'; export * from './field_icon'; export * from './table_list_view'; +export { useUrlTracker } from './use_url_tracker'; export { toMountPoint } from './util'; diff --git a/webpackShims/moment.js b/src/plugins/kibana_react/public/use_url_tracker/index.ts similarity index 90% rename from webpackShims/moment.js rename to src/plugins/kibana_react/public/use_url_tracker/index.ts index 31476d18c9562..fdceaf34e04ee 100644 --- a/webpackShims/moment.js +++ b/src/plugins/kibana_react/public/use_url_tracker/index.ts @@ -17,4 +17,4 @@ * under the License. */ -module.exports = require('../node_modules/moment/min/moment-with-locales.min.js'); +export { useUrlTracker } from './use_url_tracker'; diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx new file mode 100644 index 0000000000000..d1425a09b2f9c --- /dev/null +++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.test.tsx @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useUrlTracker } from './use_url_tracker'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createMemoryHistory } from 'history'; + +describe('useUrlTracker', () => { + const key = 'key'; + let storage = new StubBrowserStorage(); + let history = createMemoryHistory(); + beforeEach(() => { + storage = new StubBrowserStorage(); + history = createMemoryHistory(); + }); + + it('should track history changes and save them to storage', () => { + expect(storage.getItem(key)).toBeNull(); + const { unmount } = renderHook(() => { + useUrlTracker(key, history, () => false, storage); + }); + expect(storage.getItem(key)).toBe('/'); + history.push('/change'); + expect(storage.getItem(key)).toBe('/change'); + unmount(); + history.push('/other-change'); + expect(storage.getItem(key)).toBe('/change'); + }); + + it('by default should restore initial url', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, undefined, storage); + }); + expect(history.location.pathname).toBe('/change'); + }); + + it('should restore initial url if shouldRestoreUrl cb returns true', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, () => true, storage); + }); + expect(history.location.pathname).toBe('/change'); + }); + + it('should not restore initial url if shouldRestoreUrl cb returns false', () => { + storage.setItem(key, '/change'); + renderHook(() => { + useUrlTracker(key, history, () => false, storage); + }); + expect(history.location.pathname).toBe('/'); + }); +}); diff --git a/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx new file mode 100644 index 0000000000000..97e69fe22a842 --- /dev/null +++ b/src/plugins/kibana_react/public/use_url_tracker/use_url_tracker.tsx @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { History } from 'history'; +import { useLayoutEffect } from 'react'; +import { createUrlTracker } from '../../../kibana_utils/public/'; + +/** + * State management url_tracker in react hook form + * + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage so it could be restored if navigated back to the app + * + * @param key - key to use in storage + * @param history - history instance to use + * @param shouldRestoreUrl - cb if url should be restored + * @param storage - storage to use. window.sessionStorage is default + */ +export function useUrlTracker( + key: string, + history: History, + shouldRestoreUrl: (urlToRestore: string) => boolean = () => true, + storage: Storage = sessionStorage +) { + useLayoutEffect(() => { + const urlTracker = createUrlTracker(key, storage); + const urlToRestore = urlTracker.getTrackedUrl(); + if (urlToRestore && shouldRestoreUrl(urlToRestore)) { + history.replace(urlToRestore); + } + const stopTrackingUrl = urlTracker.startTrackingUrl(history); + return () => { + stopTrackingUrl(); + }; + }, [key, history]); +} diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts new file mode 100644 index 0000000000000..24f8f13f21478 --- /dev/null +++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.test.ts @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Subject } from 'rxjs'; +import { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value'; +import { toArray } from 'rxjs/operators'; +import deepEqual from 'fast-deep-equal'; + +describe('distinctUntilChangedWithInitialValue', () => { + it('should skip updates with the same value', async () => { + const subject = new Subject(); + const result = subject.pipe(distinctUntilChangedWithInitialValue(1), toArray()).toPromise(); + + subject.next(2); + subject.next(3); + subject.next(3); + subject.next(3); + subject.complete(); + + expect(await result).toEqual([2, 3]); + }); + + it('should accept promise as initial value', async () => { + const subject = new Subject(); + const result = subject + .pipe( + distinctUntilChangedWithInitialValue( + new Promise(resolve => { + resolve(1); + setTimeout(() => { + subject.next(2); + subject.next(3); + subject.next(3); + subject.next(3); + subject.complete(); + }); + }) + ), + toArray() + ) + .toPromise(); + expect(await result).toEqual([2, 3]); + }); + + it('should accept custom comparator', async () => { + const subject = new Subject(); + const result = subject + .pipe(distinctUntilChangedWithInitialValue({ test: 1 }, deepEqual), toArray()) + .toPromise(); + + subject.next({ test: 1 }); + subject.next({ test: 2 }); + subject.next({ test: 2 }); + subject.next({ test: 3 }); + subject.complete(); + + expect(await result).toEqual([{ test: 2 }, { test: 3 }]); + }); +}); diff --git a/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts new file mode 100644 index 0000000000000..6af9cc1e8ac3a --- /dev/null +++ b/src/plugins/kibana_utils/common/distinct_until_changed_with_initial_value.ts @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { MonoTypeOperatorFunction, queueScheduler, scheduled, from } from 'rxjs'; +import { concatAll, distinctUntilChanged, skip } from 'rxjs/operators'; + +export function distinctUntilChangedWithInitialValue( + initialValue: T | Promise, + compare?: (x: T, y: T) => boolean +): MonoTypeOperatorFunction { + return input$ => + scheduled( + [isPromise(initialValue) ? from(initialValue) : [initialValue], input$], + queueScheduler + ).pipe(concatAll(), distinctUntilChanged(compare), skip(1)); +} + +function isPromise(value: T | Promise): value is Promise { + return ( + !!value && + typeof value === 'object' && + 'then' in value && + typeof value.then === 'function' && + !('subscribe' in value) + ); +} diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index d13a250cedf2e..eb3bb96c8e874 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -18,3 +18,4 @@ */ export * from './defer'; +export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_with_initial_value'; diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts index 4e792ceef117a..5c50e152ad46c 100644 --- a/src/plugins/kibana_utils/demos/demos.test.ts +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -19,6 +19,7 @@ import { result as counterResult } from './state_containers/counter'; import { result as todomvcResult } from './state_containers/todomvc'; +import { result as urlSyncResult } from './state_sync/url'; describe('demos', () => { describe('state containers', () => { @@ -33,4 +34,12 @@ describe('demos', () => { ]); }); }); + + describe('state sync', () => { + test('url sync demo works', async () => { + expect(await urlSyncResult).toMatchInlineSnapshot( + `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` + ); + }); + }); }); diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts new file mode 100644 index 0000000000000..657b64f55a776 --- /dev/null +++ b/src/plugins/kibana_utils/demos/state_sync/url.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; +import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; +import { + createKbnUrlStateStorage, + syncState, + INullableBaseStateContainer, +} from '../../public/state_sync'; + +const tick = () => new Promise(resolve => setTimeout(resolve)); + +const stateContainer = createStateContainer(defaultState, pureTransitions); +const { start, stop } = syncState({ + stateContainer: withDefaultState(stateContainer, defaultState), + storageKey: '_s', + stateStorage: createKbnUrlStateStorage(), +}); + +start(); +export const result = Promise.resolve() + .then(() => { + // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers')" + + stateContainer.transitions.add({ + id: 2, + text: 'test', + completed: false, + }); + + // http://localhost/#?_s=!((completed:!f,id:0,text:'Learning+state+containers'),(completed:!f,id:2,text:test))" + + /* actual url updates happens async */ + return tick(); + }) + .then(() => { + stop(); + return window.location.href; + }); + +function withDefaultState( + // eslint-disable-next-line no-shadow + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + stateContainer.set(state || defaultState); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts index 72f3716147efa..99b49b401a8b8 100644 --- a/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts +++ b/src/plugins/kibana_utils/public/field_mapping/mapping_setup.ts @@ -19,7 +19,9 @@ import { mapValues, isString } from 'lodash'; import { FieldMappingSpec, MappingObject } from './types'; -import { ES_FIELD_TYPES } from '../../../data/public'; + +// import from ./common/types to prevent circular dependency of kibana_utils <-> data plugin +import { ES_FIELD_TYPES } from '../../../data/common/types'; /** @private */ type ShorthandFieldMapObject = FieldMappingSpec | ES_FIELD_TYPES | 'json'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index af2fc9e31b21b..0ba444c4e9395 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -27,6 +27,34 @@ export * from './render_complete'; export * from './resize_checker'; export * from './state_containers'; export * from './storage'; -export * from './storage/hashed_item_store'; -export * from './state_management/state_hash'; -export * from './state_management/url'; +export { hashedItemStore, HashedItemStore } from './storage/hashed_item_store'; +export { + createStateHash, + persistState, + retrieveState, + isStateHash, +} from './state_management/state_hash'; +export { + hashQuery, + hashUrl, + unhashUrl, + unhashQuery, + createUrlTracker, + createKbnUrlControls, + getStateFromKbnUrl, + getStatesFromKbnUrl, + setStateToKbnUrl, +} from './state_management/url'; +export { + syncState, + syncStates, + createKbnUrlStateStorage, + createSessionStorageStateStorage, + IStateSyncConfig, + ISyncStateRef, + IKbnUrlStateStorage, + INullableBaseStateContainer, + ISessionStorageStateStorage, + StartSyncStateFnType, + StopSyncStateFnType, +} from './state_sync'; diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts index 9165181299a90..95f4c35f2ce01 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts @@ -113,6 +113,13 @@ test('multiple subscribers can subscribe', () => { expect(spy2.mock.calls[1][0]).toEqual({ a: 2 }); }); +test('can create state container without transitions', () => { + const state = { foo: 'bar' }; + const stateContainer = createStateContainer(state); + expect(stateContainer.transitions).toEqual({}); + expect(stateContainer.get()).toEqual(state); +}); + test('creates impure mutators from pure mutators', () => { const { mutators } = create( {}, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts index 1ef4a1c012817..b949a9daed0ae 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts @@ -41,11 +41,11 @@ const freeze: (value: T) => RecursiveReadonly = export const createStateContainer = < State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} >( defaultState: State, - pureTransitions: PureTransitions, + pureTransitions: PureTransitions = {} as PureTransitions, pureSelectors: PureSelectors = {} as PureSelectors ): ReduxLikeStateContainer => { const data$ = new BehaviorSubject>(freeze(defaultState)); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx index 8f5810f3e147d..c1a35441b637b 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx @@ -193,12 +193,7 @@ describe('hooks', () => { describe('useTransitions', () => { test('useTransitions hook returns mutations that can update state', () => { - const { store } = create< - { - cnt: number; - }, - any - >( + const { store } = create( { cnt: 0, }, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts index e94165cc48376..45b34b13251f4 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 e0a1a18972635..e120f60e72b8f 100644 --- a/src/plugins/kibana_utils/public/state_containers/types.ts +++ b/src/plugins/kibana_utils/public/state_containers/types.ts @@ -42,7 +42,7 @@ export interface BaseStateContainer { export interface StateContainer< State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; @@ -51,7 +51,7 @@ export interface StateContainer< export interface ReduxLikeStateContainer< State, - PureTransitions extends object, + PureTransitions extends object = {}, PureSelectors extends object = {} > extends StateContainer { getState: () => RecursiveReadonly; diff --git a/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts new file mode 100644 index 0000000000000..c535e965aa772 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/state_encoder/encode_decode_state.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import rison, { RisonValue } from 'rison-node'; +import { isStateHash, retrieveState, persistState } from '../state_hash'; + +// should be: +// export function decodeState(expandedOrHashedState: string) +// but this leads to the chain of types mismatches up to BaseStateContainer interfaces, +// as in state containers we don't have any restrictions on state shape +export function decodeState(expandedOrHashedState: string): State { + if (isStateHash(expandedOrHashedState)) { + return retrieveState(expandedOrHashedState); + } else { + return (rison.decode(expandedOrHashedState) as unknown) as State; + } +} + +// should be: +// export function encodeState(expandedOrHashedState: string) +// but this leads to the chain of types mismatches up to BaseStateContainer interfaces, +// as in state containers we don't have any restrictions on state shape +export function encodeState(state: State, useHash: boolean): string { + if (useHash) { + return persistState(state); + } else { + return rison.encode((state as unknown) as RisonValue); + } +} + +export function hashedStateToExpandedState(expandedOrHashedState: string): string { + if (isStateHash(expandedOrHashedState)) { + return encodeState(retrieveState(expandedOrHashedState), false); + } + + return expandedOrHashedState; +} + +export function expandedStateToHashedState(expandedOrHashedState: string): string { + if (isStateHash(expandedOrHashedState)) { + return expandedOrHashedState; + } + + return persistState(decodeState(expandedOrHashedState)); +} diff --git a/webpackShims/angular.js b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts similarity index 85% rename from webpackShims/angular.js rename to src/plugins/kibana_utils/public/state_management/state_encoder/index.ts index 4857f0f8975bc..da1382720faff 100644 --- a/webpackShims/angular.js +++ b/src/plugins/kibana_utils/public/state_management/state_encoder/index.ts @@ -17,6 +17,9 @@ * under the License. */ -require('jquery'); -require('../node_modules/angular/angular'); -module.exports = window.angular; +export { + encodeState, + decodeState, + expandedStateToHashedState, + hashedStateToExpandedState, +} from './encode_decode_state'; diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts index 0e52c4c55872d..24c3c57613477 100644 --- a/src/plugins/kibana_utils/public/state_management/state_hash/index.ts +++ b/src/plugins/kibana_utils/public/state_management/state_hash/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './state_hash'; +export { isStateHash, createStateHash, persistState, retrieveState } from './state_hash'; diff --git a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts index a3eb5272b112d..f56d71297c030 100644 --- a/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts +++ b/src/plugins/kibana_utils/public/state_management/state_hash/state_hash.ts @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { Sha256 } from '../../../../../core/public/utils'; import { hashedItemStore } from '../../storage/hashed_item_store'; @@ -52,3 +53,46 @@ export function createStateHash( export function isStateHash(str: string) { return String(str).indexOf(HASH_PREFIX) === 0; } + +export function retrieveState(stateHash: string): State { + const json = hashedItemStore.getItem(stateHash); + const throwUnableToRestoreUrlError = () => { + throw new Error( + i18n.translate('kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage', { + defaultMessage: + 'Unable to completely restore the URL, be sure to use the share functionality.', + }) + ); + }; + if (json === null) { + return throwUnableToRestoreUrlError(); + } + try { + return JSON.parse(json); + } catch (e) { + return throwUnableToRestoreUrlError(); + } +} + +export function persistState(state: State): string { + const json = JSON.stringify(state); + const hash = createStateHash(json); + + const isItemSet = hashedItemStore.setItem(hash, json); + if (isItemSet) return hash; + // If we ran out of space trying to persist the state, notify the user. + const message = i18n.translate( + 'kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage', + { + defaultMessage: + 'Kibana is unable to store history items in your session ' + + `because it is full and there don't seem to be items any items safe ` + + 'to delete.\n\n' + + 'This can usually be fixed by moving to a fresh tab, but could ' + + 'be caused by a larger issue. If you are seeing this message regularly, ' + + 'please file an issue at {gitHubIssuesUrl}.', + values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' }, + } + ); + throw new Error(message); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/format.test.ts b/src/plugins/kibana_utils/public/state_management/url/format.test.ts new file mode 100644 index 0000000000000..728f069840c72 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/format.test.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { replaceUrlHashQuery } from './format'; + +describe('format', () => { + describe('replaceUrlHashQuery', () => { + it('should add hash query to url without hash', () => { + const url = 'http://localhost:5601/oxf/app/kibana'; + expect(replaceUrlHashQuery(url, () => ({ test: 'test' }))).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#?test=test"` + ); + }); + + it('should replace hash query', () => { + const url = 'http://localhost:5601/oxf/app/kibana#?test=test'; + expect( + replaceUrlHashQuery(url, query => ({ + ...query, + test1: 'test1', + })) + ).toMatchInlineSnapshot(`"http://localhost:5601/oxf/app/kibana#?test=test&test1=test1"`); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/format.ts b/src/plugins/kibana_utils/public/state_management/url/format.ts new file mode 100644 index 0000000000000..988ee08627382 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/format.ts @@ -0,0 +1,41 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { format as formatUrl } from 'url'; +import { ParsedUrlQuery } from 'querystring'; +import { parseUrl, parseUrlHash } from './parse'; +import { stringifyQueryString } from './stringify_query_string'; + +export function replaceUrlHashQuery( + rawUrl: string, + queryReplacer: (query: ParsedUrlQuery) => ParsedUrlQuery +) { + const url = parseUrl(rawUrl); + const hash = parseUrlHash(rawUrl); + const newQuery = queryReplacer(hash?.query || {}); + const searchQueryString = stringifyQueryString(newQuery); + if ((!hash || !hash.search) && !searchQueryString) return rawUrl; // nothing to change. return original url + return formatUrl({ + ...url, + hash: formatUrl({ + pathname: hash?.pathname || '', + search: searchQueryString, + }), + }); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts index a85158acddefd..ec87b8464ac2d 100644 --- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.test.ts @@ -29,13 +29,6 @@ describe('hash unhash url', () => { describe('hash url', () => { describe('does nothing', () => { - it('if missing input', () => { - expect(() => { - // @ts-ignore - hashUrl(); - }).not.toThrowError(); - }); - it('if url is empty', () => { const url = ''; expect(hashUrl(url)).toBe(url); diff --git a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts index 872e7953f938b..a29f8bb9ac635 100644 --- a/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts +++ b/src/plugins/kibana_utils/public/state_management/url/hash_unhash_url.ts @@ -17,13 +17,8 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import rison, { RisonObject } from 'rison-node'; -import { stringify as stringifyQueryString } from 'querystring'; -import encodeUriQuery from 'encode-uri-query'; -import { format as formatUrl, parse as parseUrl } from 'url'; -import { hashedItemStore } from '../../storage/hashed_item_store'; -import { createStateHash, isStateHash } from '../state_hash'; +import { expandedStateToHashedState, hashedStateToExpandedState } from '../state_encoder'; +import { replaceUrlHashQuery } from './format'; export type IParsedUrlQuery = Record; @@ -32,8 +27,8 @@ interface IUrlQueryMapperOptions { } export type IUrlQueryReplacerOptions = IUrlQueryMapperOptions; -export const unhashQuery = createQueryMapper(stateHashToRisonState); -export const hashQuery = createQueryMapper(risonStateToStateHash); +export const unhashQuery = createQueryMapper(hashedStateToExpandedState); +export const hashQuery = createQueryMapper(expandedStateToHashedState); export const unhashUrl = createQueryReplacer(unhashQuery); export const hashUrl = createQueryReplacer(hashQuery); @@ -61,97 +56,5 @@ function createQueryReplacer( queryMapper: (q: IParsedUrlQuery, options?: IUrlQueryMapperOptions) => IParsedUrlQuery, options?: IUrlQueryReplacerOptions ) { - return (url: string) => { - if (!url) return url; - - const parsedUrl = parseUrl(url, true); - if (!parsedUrl.hash) return url; - - const appUrl = parsedUrl.hash.slice(1); // trim the # - if (!appUrl) return url; - - const appUrlParsed = parseUrl(appUrl, true); - if (!appUrlParsed.query) return url; - - const changedAppQuery = queryMapper(appUrlParsed.query, options); - - // encodeUriQuery implements the less-aggressive encoding done naturally by - // the browser. We use it to generate the same urls the browser would - const changedAppQueryString = stringifyQueryString(changedAppQuery, undefined, undefined, { - encodeURIComponent: encodeUriQuery, - }); - - return formatUrl({ - ...parsedUrl, - hash: formatUrl({ - pathname: appUrlParsed.pathname, - search: changedAppQueryString, - }), - }); - }; -} - -// TODO: this helper should be merged with or replaced by -// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts -// maybe to become simplified stateless version -export function retrieveState(stateHash: string): RisonObject { - const json = hashedItemStore.getItem(stateHash); - const throwUnableToRestoreUrlError = () => { - throw new Error( - i18n.translate('kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage', { - defaultMessage: - 'Unable to completely restore the URL, be sure to use the share functionality.', - }) - ); - }; - if (json === null) { - return throwUnableToRestoreUrlError(); - } - try { - return JSON.parse(json); - } catch (e) { - return throwUnableToRestoreUrlError(); - } -} - -// TODO: this helper should be merged with or replaced by -// src/legacy/ui/public/state_management/state_storage/hashed_item_store.ts -// maybe to become simplified stateless version -export function persistState(state: RisonObject): string { - const json = JSON.stringify(state); - const hash = createStateHash(json); - - const isItemSet = hashedItemStore.setItem(hash, json); - if (isItemSet) return hash; - // If we ran out of space trying to persist the state, notify the user. - const message = i18n.translate( - 'kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage', - { - defaultMessage: - 'Kibana is unable to store history items in your session ' + - `because it is full and there don't seem to be items any items safe ` + - 'to delete.\n\n' + - 'This can usually be fixed by moving to a fresh tab, but could ' + - 'be caused by a larger issue. If you are seeing this message regularly, ' + - 'please file an issue at {gitHubIssuesUrl}.', - values: { gitHubIssuesUrl: 'https://github.com/elastic/kibana/issues' }, - } - ); - throw new Error(message); -} - -function stateHashToRisonState(stateHashOrRison: string): string { - if (isStateHash(stateHashOrRison)) { - return rison.encode(retrieveState(stateHashOrRison)); - } - - return stateHashOrRison; -} - -function risonStateToStateHash(stateHashOrRison: string): string | null { - if (isStateHash(stateHashOrRison)) { - return stateHashOrRison; - } - - return persistState(rison.decode(stateHashOrRison) as RisonObject); + return (url: string) => replaceUrlHashQuery(url, query => queryMapper(query, options)); } diff --git a/src/plugins/kibana_utils/public/state_management/url/index.ts b/src/plugins/kibana_utils/public/state_management/url/index.ts index 30c5696233db7..40491bf7a274b 100644 --- a/src/plugins/kibana_utils/public/state_management/url/index.ts +++ b/src/plugins/kibana_utils/public/state_management/url/index.ts @@ -17,4 +17,12 @@ * under the License. */ -export * from './hash_unhash_url'; +export { hashUrl, hashQuery, unhashUrl, unhashQuery } from './hash_unhash_url'; +export { + createKbnUrlControls, + setStateToKbnUrl, + getStateFromKbnUrl, + getStatesFromKbnUrl, + IKbnUrlControls, +} from './kbn_url_storage'; +export { createUrlTracker } from './url_tracker'; diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts new file mode 100644 index 0000000000000..f1c527d3d5309 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts @@ -0,0 +1,246 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import '../../storage/hashed_item_store/mock'; +import { + History, + createBrowserHistory, + createHashHistory, + createMemoryHistory, + createPath, +} from 'history'; +import { + getRelativeToHistoryPath, + createKbnUrlControls, + IKbnUrlControls, + setStateToKbnUrl, + getStateFromKbnUrl, +} from './kbn_url_storage'; + +describe('kbn_url_storage', () => { + describe('getStateFromUrl & setStateToUrl', () => { + const url = 'http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id'; + const state1 = { + testStr: '123', + testNumber: 0, + testObj: { test: '123' }, + testNull: null, + testArray: [1, 2, {}], + }; + const state2 = { + test: '123', + }; + + it('should set expanded state to url', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: false }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(testArray:!(1,2,()),testNull:!n,testNumber:0,testObj:(test:'123'),testStr:'123')"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: false }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=(test:'123')"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState2).toEqual(state2); + }); + + it('should set hashed state to url', () => { + let newUrl = setStateToKbnUrl('_s', state1, { useHash: true }, url); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@a897fac"` + ); + const retrievedState1 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState1).toEqual(state1); + + newUrl = setStateToKbnUrl('_s', state2, { useHash: true }, newUrl); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_s=h@40f94d5"` + ); + const retrievedState2 = getStateFromKbnUrl('_s', newUrl); + expect(retrievedState2).toEqual(state2); + }); + }); + + describe('urlControls', () => { + let history: History; + let urlControls: IKbnUrlControls; + beforeEach(() => { + history = createMemoryHistory(); + urlControls = createKbnUrlControls(history); + }); + + const getCurrentUrl = () => createPath(history.location); + it('should update url', () => { + urlControls.update('/1', false); + + expect(getCurrentUrl()).toBe('/1'); + expect(history.length).toBe(2); + + urlControls.update('/2', true); + + expect(getCurrentUrl()).toBe('/2'); + expect(history.length).toBe(2); + }); + + it('should update url async', async () => { + const pr1 = urlControls.updateAsync(() => '/1', false); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', false); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + }); + + it('should push url state if at least 1 push in async chain', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(2); + }); + + it('should replace url state if all updates in async chain are replace', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', true); + const pr3 = urlControls.updateAsync(() => '/3', true); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(1); + }); + + it('should listen for url updates', async () => { + const cb = jest.fn(); + urlControls.listen(cb); + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', true); + const pr3 = urlControls.updateAsync(() => '/3', true); + await Promise.all([pr1, pr2, pr3]); + + urlControls.update('/4', false); + urlControls.update('/5', true); + + expect(cb).toHaveBeenCalledTimes(3); + }); + + it('should flush async url updates', async () => { + const pr1 = urlControls.updateAsync(() => '/1', false); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', false); + expect(getCurrentUrl()).toBe('/'); + urlControls.flush(); + expect(getCurrentUrl()).toBe('/3'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + }); + + it('flush should take priority over regular replace behaviour', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + urlControls.flush(false); + expect(getCurrentUrl()).toBe('/3'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/3'); + expect(history.length).toBe(2); + }); + + it('should cancel async url updates', async () => { + const pr1 = urlControls.updateAsync(() => '/1', true); + const pr2 = urlControls.updateAsync(() => '/2', false); + const pr3 = urlControls.updateAsync(() => '/3', true); + urlControls.cancel(); + expect(getCurrentUrl()).toBe('/'); + await Promise.all([pr1, pr2, pr3]); + expect(getCurrentUrl()).toBe('/'); + }); + }); + + describe('getRelativeToHistoryPath', () => { + it('should extract path relative to browser history without basename', () => { + const history = createBrowserHistory(); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to browser history with basename', () => { + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const history1 = createBrowserHistory({ basename: '/oxf/app/' }); + const relativePath1 = getRelativeToHistoryPath(url, history1); + expect(relativePath1).toEqual( + "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + + const history2 = createBrowserHistory({ basename: '/oxf/app/kibana/' }); + const relativePath2 = getRelativeToHistoryPath(url, history2); + expect(relativePath2).toEqual( + "#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to browser history with basename from relative url', () => { + const history = createBrowserHistory({ basename: '/oxf/app/' }); + const url = + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history without basename', () => { + const history = createHashHistory(); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history with basename', () => { + const history = createHashHistory({ basename: 'management' }); + const url = + "http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + + it('should extract path relative to hash history with basename from relative url', () => { + const history = createHashHistory({ basename: 'management' }); + const url = + "/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')"; + const relativePath = getRelativeToHistoryPath(url, history); + expect(relativePath).toEqual( + "/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'')" + ); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts new file mode 100644 index 0000000000000..03c136ea3d092 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts @@ -0,0 +1,235 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { format as formatUrl } from 'url'; +import { createBrowserHistory, History } from 'history'; +import { decodeState, encodeState } from '../state_encoder'; +import { getCurrentUrl, parseUrl, parseUrlHash } from './parse'; +import { stringifyQueryString } from './stringify_query_string'; +import { replaceUrlHashQuery } from './format'; + +/** + * Parses a kibana url and retrieves all the states encoded into url, + * Handles both expanded rison state and hashed state (where the actual state stored in sessionStorage) + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * will return object: + * {_a: {tab: 'indexedFields'}, _b: {f: 'test', i: '', l: ''}}; + */ +export function getStatesFromKbnUrl( + url: string = window.location.href, + keys?: string[] +): Record { + const query = parseUrlHash(url)?.query; + + if (!query) return {}; + const decoded: Record = {}; + Object.entries(query) + .filter(([key]) => (keys ? keys.includes(key) : true)) + .forEach(([q, value]) => { + decoded[q] = decodeState(value as string); + }); + + return decoded; +} + +/** + * Retrieves specific state from url by key + * e.g.: + * + * given an url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * and key '_a' + * will return object: + * {tab: 'indexedFields'} + */ +export function getStateFromKbnUrl( + key: string, + url: string = window.location.href +): State | null { + return (getStatesFromKbnUrl(url, [key])[key] as State) || null; +} + +/** + * Sets state to the url by key and returns a new url string. + * Doesn't actually updates history + * + * e.g.: + * given a url: http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:indexedFields)&_b=(f:test,i:'',l:'') + * key: '_a' + * and state: {tab: 'other'} + * + * will return url: + * http://localhost:5601/oxf/app/kibana#/management/kibana/index_patterns/id?_a=(tab:other)&_b=(f:test,i:'',l:'') + */ +export function setStateToKbnUrl( + key: string, + state: State, + { useHash = false }: { useHash: boolean } = { useHash: false }, + rawUrl = window.location.href +): string { + return replaceUrlHashQuery(rawUrl, query => { + const encoded = encodeState(state, useHash); + return { + ...query, + [key]: encoded, + }; + }); +} + +/** + * A tiny wrapper around history library to listen for url changes and update url + * History library handles a bunch of cross browser edge cases + */ +export interface IKbnUrlControls { + /** + * Listen for url changes + * @param cb - get's called when url has been changed + */ + listen: (cb: () => void) => () => void; + + /** + * Updates url synchronously + * @param url - url to update to + * @param replace - use replace instead of push + */ + update: (url: string, replace: boolean) => string; + + /** + * Schedules url update to next microtask, + * Useful to batch sync changes to url to cause only one browser history update + * @param updater - fn which receives current url and should return next url to update to + * @param replace - use replace instead of push + */ + updateAsync: (updater: UrlUpdaterFnType, replace?: boolean) => Promise; + + /** + * Synchronously flushes scheduled url updates + * @param replace - if replace passed in, then uses it instead of push. Otherwise push or replace is picked depending on updateQueue + */ + flush: (replace?: boolean) => string; + + /** + * Cancels any pending url updates + */ + cancel: () => void; +} +export type UrlUpdaterFnType = (currentUrl: string) => string; + +export const createKbnUrlControls = ( + history: History = createBrowserHistory() +): IKbnUrlControls => { + const updateQueue: Array<(currentUrl: string) => string> = []; + + // if we should replace or push with next async update, + // if any call in a queue asked to push, then we should push + let shouldReplace = true; + + function updateUrl(newUrl: string, replace = false): string { + const currentUrl = getCurrentUrl(); + if (newUrl === currentUrl) return currentUrl; // skip update + + const historyPath = getRelativeToHistoryPath(newUrl, history); + + if (replace) { + history.replace(historyPath); + } else { + history.push(historyPath); + } + + return getCurrentUrl(); + } + + // queue clean up + function cleanUp() { + updateQueue.splice(0, updateQueue.length); + shouldReplace = true; + } + + // runs scheduled url updates + function flush(replace = shouldReplace) { + if (updateQueue.length === 0) return getCurrentUrl(); + const resultUrl = updateQueue.reduce((url, nextUpdate) => nextUpdate(url), getCurrentUrl()); + + cleanUp(); + + const newUrl = updateUrl(resultUrl, replace); + return newUrl; + } + + return { + listen: (cb: () => void) => + history.listen(() => { + cb(); + }), + update: (newUrl: string, replace = false) => updateUrl(newUrl, replace), + updateAsync: (updater: (currentUrl: string) => string, replace = false) => { + updateQueue.push(updater); + if (shouldReplace) { + shouldReplace = replace; + } + + // Schedule url update to the next microtask + // this allows to batch synchronous url changes + return Promise.resolve().then(() => { + return flush(); + }); + }, + flush: (replace?: boolean) => { + return flush(replace); + }, + cancel: () => { + cleanUp(); + }, + }; +}; + +/** + * Depending on history configuration extracts relative path for history updates + * 4 possible cases (see tests): + * 1. Browser history with empty base path + * 2. Browser history with base path + * 3. Hash history with empty base path + * 4. Hash history with base path + */ +export function getRelativeToHistoryPath(absoluteUrl: string, history: History): History.Path { + function stripBasename(path: string = '') { + const stripLeadingHash = (_: string) => (_.charAt(0) === '#' ? _.substr(1) : _); + const stripTrailingSlash = (_: string) => + _.charAt(_.length - 1) === '/' ? _.substr(0, _.length - 1) : _; + const baseName = stripLeadingHash(stripTrailingSlash(history.createHref({}))); + return path.startsWith(baseName) ? path.substr(baseName.length) : path; + } + const isHashHistory = history.createHref({}).includes('#'); + const parsedUrl = isHashHistory ? parseUrlHash(absoluteUrl)! : parseUrl(absoluteUrl); + const parsedHash = isHashHistory ? null : parseUrlHash(absoluteUrl); + + return formatUrl({ + pathname: stripBasename(parsedUrl.pathname), + search: stringifyQueryString(parsedUrl.query), + hash: parsedHash + ? formatUrl({ + pathname: parsedHash.pathname, + search: stringifyQueryString(parsedHash.query), + }) + : parsedUrl.hash, + }); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.test.ts b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts new file mode 100644 index 0000000000000..774f18b734514 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/parse.test.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parseUrlHash } from './parse'; + +describe('parseUrlHash', () => { + it('should return null if no hash', () => { + expect(parseUrlHash('http://localhost:5601/oxf/app/kibana')).toBeNull(); + }); + + it('should return parsed hash', () => { + expect(parseUrlHash('http://localhost:5601/oxf/app/kibana/#/path?test=test')).toMatchObject({ + pathname: '/path', + query: { + test: 'test', + }, + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/parse.ts b/src/plugins/kibana_utils/public/state_management/url/parse.ts new file mode 100644 index 0000000000000..95041d0662f56 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/parse.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parse as _parseUrl } from 'url'; + +export const parseUrl = (url: string) => _parseUrl(url, true); +export const parseUrlHash = (url: string) => { + const hash = parseUrl(url).hash; + return hash ? parseUrl(hash.slice(1)) : null; +}; +export const getCurrentUrl = () => window.location.href; +export const parseCurrentUrl = () => parseUrl(getCurrentUrl()); +export const parseCurrentUrlHash = () => parseUrlHash(getCurrentUrl()); diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts new file mode 100644 index 0000000000000..3ca6cb4214682 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { encodeUriQuery, stringifyQueryString } from './stringify_query_string'; + +describe('stringifyQueryString', () => { + it('stringifyQueryString', () => { + expect( + stringifyQueryString({ + a: 'asdf1234asdf', + b: "-_.!~*'() -_.!~*'()", + c: ':@$, :@$,', + d: "&;=+# &;=+#'", + f: ' ', + g: 'null', + }) + ).toMatchInlineSnapshot( + `"a=asdf1234asdf&b=-_.!~*'()%20-_.!~*'()&c=:@$,%20:@$,&d=%26;%3D%2B%23%20%26;%3D%2B%23'&f=%20&g=null"` + ); + }); +}); + +describe('encodeUriQuery', function() { + it('should correctly encode uri query and not encode chars defined as pchar set in rfc3986', () => { + // don't encode alphanum + expect(encodeUriQuery('asdf1234asdf')).toBe('asdf1234asdf'); + + // don't encode unreserved + expect(encodeUriQuery("-_.!~*'() -_.!~*'()")).toBe("-_.!~*'()+-_.!~*'()"); + + // don't encode the rest of pchar + expect(encodeUriQuery(':@$, :@$,')).toBe(':@$,+:@$,'); + + // encode '&', ';', '=', '+', and '#' + expect(encodeUriQuery('&;=+# &;=+#')).toBe('%26;%3D%2B%23+%26;%3D%2B%23'); + + // encode ' ' as '+' + expect(encodeUriQuery(' ')).toBe('++'); + + // encode ' ' as '%20' when a flag is used + expect(encodeUriQuery(' ', true)).toBe('%20%20'); + + // do not encode `null` as '+' when flag is used + expect(encodeUriQuery('null', true)).toBe('null'); + + // do not encode `null` with no flag + expect(encodeUriQuery('null')).toBe('null'); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts new file mode 100644 index 0000000000000..e951dfac29c02 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/stringify_query_string.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { stringify, ParsedUrlQuery } from 'querystring'; + +// encodeUriQuery implements the less-aggressive encoding done naturally by +// the browser. We use it to generate the same urls the browser would +export const stringifyQueryString = (query: ParsedUrlQuery) => + stringify(query, undefined, undefined, { + // encode spaces with %20 is needed to produce the same queries as angular does + // https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1377 + encodeURIComponent: (val: string) => encodeUriQuery(val, true), + }); + +/** + * Extracted from angular.js + * repo: https://github.com/angular/angular.js + * license: MIT - https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/LICENSE + * source: https://github.com/angular/angular.js/blob/51c516e7d4f2d10b0aaa4487bd0b52772022207a/src/Angular.js#L1413-L1432 + */ + +/** + * This method is intended for encoding *key* or *value* parts of query component. We need a custom + * method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be + * encoded per http://tools.ietf.org/html/rfc3986: + * query = *( pchar / "/" / "?" ) + * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * pct-encoded = "%" HEXDIG HEXDIG + * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" + * / "*" / "+" / "," / ";" / "=" + */ +export function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) { + return encodeURIComponent(val) + .replace(/%40/gi, '@') + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%3B/gi, ';') + .replace(/%20/g, pctEncodeSpaces ? '%20' : '+'); +} diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts new file mode 100644 index 0000000000000..d7e5f99ffb700 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createUrlTracker, IUrlTracker } from './url_tracker'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createMemoryHistory, History } from 'history'; + +describe('urlTracker', () => { + let storage: StubBrowserStorage; + let history: History; + let urlTracker: IUrlTracker; + beforeEach(() => { + storage = new StubBrowserStorage(); + history = createMemoryHistory(); + urlTracker = createUrlTracker('test', storage); + }); + + it('should return null if no tracked url', () => { + expect(urlTracker.getTrackedUrl()).toBeNull(); + }); + + it('should return last tracked url', () => { + urlTracker.trackUrl('http://localhost:4200'); + urlTracker.trackUrl('http://localhost:4201'); + urlTracker.trackUrl('http://localhost:4202'); + expect(urlTracker.getTrackedUrl()).toBe('http://localhost:4202'); + }); + + it('should listen to history and track updates', () => { + const stop = urlTracker.startTrackingUrl(history); + expect(urlTracker.getTrackedUrl()).toBe('/'); + history.push('/1'); + history.replace('/2'); + expect(urlTracker.getTrackedUrl()).toBe('/2'); + + stop(); + history.replace('/3'); + expect(urlTracker.getTrackedUrl()).toBe('/2'); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts new file mode 100644 index 0000000000000..89e72e94ba6b4 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_management/url/url_tracker.ts @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createBrowserHistory, History, Location } from 'history'; +import { getRelativeToHistoryPath } from './kbn_url_storage'; + +export interface IUrlTracker { + startTrackingUrl: (history?: History) => () => void; + getTrackedUrl: () => string | null; + trackUrl: (url: string) => void; +} +/** + * Replicates what src/legacy/ui/public/chrome/api/nav.ts did + * Persists the url in sessionStorage so it could be restored if navigated back to the app + */ +export function createUrlTracker(key: string, storage: Storage = sessionStorage): IUrlTracker { + return { + startTrackingUrl(history: History = createBrowserHistory()) { + const track = (location: Location) => { + const url = getRelativeToHistoryPath(history.createHref(location), history); + storage.setItem(key, url); + }; + track(history.location); + return history.listen(track); + }, + getTrackedUrl() { + return storage.getItem(key); + }, + trackUrl(url: string) { + storage.setItem(key, url); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/index.ts b/src/plugins/kibana_utils/public/state_sync/index.ts new file mode 100644 index 0000000000000..1dfa998c5bb9d --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/index.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + createSessionStorageStateStorage, + createKbnUrlStateStorage, + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from './state_sync_state_storage'; +export { IStateSyncConfig, INullableBaseStateContainer } from './types'; +export { + syncState, + syncStates, + StopSyncStateFnType, + StartSyncStateFnType, + ISyncStateRef, +} from './state_sync'; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts new file mode 100644 index 0000000000000..cc513bc674d0f --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -0,0 +1,308 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BaseStateContainer, createStateContainer } from '../state_containers'; +import { + defaultState, + pureTransitions, + TodoActions, + TodoState, +} from '../../demos/state_containers/todomvc'; +import { syncState, syncStates } from './state_sync'; +import { IStateStorage } from './state_sync_state_storage/types'; +import { Observable, Subject } from 'rxjs'; +import { + createSessionStorageStateStorage, + createKbnUrlStateStorage, + IKbnUrlStateStorage, + ISessionStorageStateStorage, +} from './state_sync_state_storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { createBrowserHistory, History } from 'history'; +import { INullableBaseStateContainer } from './types'; + +describe('state_sync', () => { + describe('basic', () => { + const container = createStateContainer(defaultState, pureTransitions); + beforeEach(() => { + container.set(defaultState); + }); + const storageChange$ = new Subject(); + let testStateStorage: IStateStorage; + + beforeEach(() => { + testStateStorage = { + set: jest.fn(), + get: jest.fn(), + change$: (key: string) => storageChange$.asObservable() as Observable, + }; + }); + + it('should sync state to storage', () => { + const key = '_s'; + const { start, stop } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + // initial sync of state to storage is not happening + expect(testStateStorage.set).not.toBeCalled(); + + container.transitions.add({ + id: 1, + text: 'Learning transitions...', + completed: false, + }); + expect(testStateStorage.set).toBeCalledWith(key, container.getState()); + stop(); + }); + + it('should sync storage to state', () => { + const key = '_s'; + const storageState1 = [{ id: 1, text: 'todo', completed: false }]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState1); + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + // initial sync of storage to state is not happening + expect(container.getState()).toEqual(defaultState); + + const storageState2 = [{ id: 1, text: 'todo', completed: true }]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); + storageChange$.next(storageState2); + + expect(container.getState()).toEqual(storageState2); + + stop(); + }); + + it('should not update storage if no actual state change happened', () => { + const key = '_s'; + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + (testStateStorage.set as jest.Mock).mockClear(); + + container.set(defaultState); + expect(testStateStorage.set).not.toBeCalled(); + + stop(); + }); + + it('should not update state container if no actual storage change happened', () => { + const key = '_s'; + const { stop, start } = syncState({ + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: testStateStorage, + }); + start(); + + const originalState = container.getState(); + const storageState = [...originalState]; + (testStateStorage.get as jest.Mock).mockImplementation(() => storageState); + storageChange$.next(storageState); + + expect(container.getState()).toBe(originalState); + + stop(); + }); + + it('storage change to null should notify state', () => { + container.set([{ completed: false, id: 1, text: 'changed' }]); + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: '_s', + stateStorage: testStateStorage, + }, + ]); + start(); + + (testStateStorage.get as jest.Mock).mockImplementation(() => null); + storageChange$.next(null); + + expect(container.getState()).toEqual(defaultState); + + stop(); + }); + }); + + describe('integration', () => { + const key = '_s'; + const container = createStateContainer(defaultState, pureTransitions); + + let sessionStorage: StubBrowserStorage; + let sessionStorageSyncStrategy: ISessionStorageStateStorage; + let history: History; + let urlSyncStrategy: IKbnUrlStateStorage; + const getCurrentUrl = () => history.createHref(history.location); + const tick = () => new Promise(resolve => setTimeout(resolve)); + + beforeEach(() => { + container.set(defaultState); + + window.location.href = '/'; + sessionStorage = new StubBrowserStorage(); + sessionStorageSyncStrategy = createSessionStorageStateStorage(sessionStorage); + history = createBrowserHistory(); + urlSyncStrategy = createKbnUrlStateStorage({ useHash: false, history }); + }); + + it('change to one storage should also update other storage', () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: sessionStorageSyncStrategy, + }, + ]); + start(); + + const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; + history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); + + expect(container.getState()).toEqual(newStateFromUrl); + expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); + + stop(); + }); + + it('KbnUrlSyncStrategy applies url updates asynchronously to trigger single history change', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + await tick(); + expect(history.length).toBe(startHistoryLength + 1); + + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + stop(); + }); + + it('KbnUrlSyncStrategy supports flushing url updates synchronously and triggers single history change', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + urlSyncStrategy.flush(); + + expect(history.length).toBe(startHistoryLength + 1); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + await tick(); + + expect(history.length).toBe(startHistoryLength + 1); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + ); + + stop(); + }); + + it('KbnUrlSyncStrategy supports cancellation of pending updates ', async () => { + const { stop, start } = syncStates([ + { + stateContainer: withDefaultState(container, defaultState), + storageKey: key, + stateStorage: urlSyncStrategy, + }, + ]); + start(); + + const startHistoryLength = history.length; + container.transitions.add({ id: 2, text: '2', completed: false }); + container.transitions.add({ id: 3, text: '3', completed: false }); + container.transitions.completeAll(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + urlSyncStrategy.cancel(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + await tick(); + + expect(history.length).toBe(startHistoryLength); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + + stop(); + }); + }); +}); + +function withDefaultState( + stateContainer: BaseStateContainer, + // eslint-disable-next-line no-shadow + defaultState: State +): INullableBaseStateContainer { + return { + ...stateContainer, + set: (state: State | null) => { + stateContainer.set(state || defaultState); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts new file mode 100644 index 0000000000000..f0ef1423dec71 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -0,0 +1,171 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EMPTY, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import defaultComparator from 'fast-deep-equal'; +import { IStateSyncConfig } from './types'; +import { IStateStorage } from './state_sync_state_storage'; +import { distinctUntilChangedWithInitialValue } from '../../common'; + +/** + * Utility for syncing application state wrapped in state container + * with some kind of storage (e.g. URL) + * + * Examples: + * + * 1. the simplest use case + * const stateStorage = createKbnUrlStateStorage(); + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage + * }); + * + * 2. conditionally configuring sync strategy + * const stateStorage = createKbnUrlStateStorage({useHash: config.get('state:stateContainerInSessionStorage')}) + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage + * }); + * + * 3. implementing custom sync strategy + * const localStorageStateStorage = { + * set: (storageKey, state) => localStorage.setItem(storageKey, JSON.stringify(state)), + * get: (storageKey) => localStorage.getItem(storageKey) ? JSON.parse(localStorage.getItem(storageKey)) : null + * }; + * syncState({ + * storageKey: '_s', + * stateContainer, + * stateStorage: localStorageStateStorage + * }); + * + * 4. Transform state before serialising + * Useful for: + * * Migration / backward compatibility + * * Syncing part of state + * * Providing default values + * const stateToStorage = (s) => ({ tab: s.tab }); + * syncState({ + * storageKey: '_s', + * stateContainer: { + * get: () => stateToStorage(stateContainer.get()), + * set: stateContainer.set(({ tab }) => ({ ...stateContainer.get(), tab }), + * state$: stateContainer.state$.pipe(map(stateToStorage)) + * }, + * stateStorage + * }); + * + * Caveats: + * + * 1. It is responsibility of consumer to make sure that initial app state and storage are in sync before starting syncing + * No initial sync happens when syncState() is called + */ +export type StopSyncStateFnType = () => void; +export type StartSyncStateFnType = () => void; +export interface ISyncStateRef { + // stop syncing state with storage + stop: StopSyncStateFnType; + // start syncing state with storage + start: StartSyncStateFnType; +} +export function syncState({ + storageKey, + stateStorage, + stateContainer, +}: IStateSyncConfig): ISyncStateRef { + const subscriptions: Subscription[] = []; + + const updateState = () => { + const newState = stateStorage.get(storageKey); + const oldState = stateContainer.get(); + if (!defaultComparator(newState, oldState)) { + stateContainer.set(newState); + } + }; + + const updateStorage = () => { + const newStorageState = stateContainer.get(); + const oldStorageState = stateStorage.get(storageKey); + if (!defaultComparator(newStorageState, oldStorageState)) { + stateStorage.set(storageKey, newStorageState); + } + }; + + const onStateChange$ = stateContainer.state$.pipe( + distinctUntilChangedWithInitialValue(stateContainer.get(), defaultComparator), + tap(() => updateStorage()) + ); + + const onStorageChange$ = stateStorage.change$ + ? stateStorage.change$(storageKey).pipe( + distinctUntilChangedWithInitialValue(stateStorage.get(storageKey), defaultComparator), + tap(() => { + updateState(); + }) + ) + : EMPTY; + + return { + stop: () => { + // if stateStorage has any cancellation logic, then run it + if (stateStorage.cancel) { + stateStorage.cancel(); + } + + subscriptions.forEach(s => s.unsubscribe()); + subscriptions.splice(0, subscriptions.length); + }, + start: () => { + if (subscriptions.length > 0) { + throw new Error("syncState: can't start syncing state, when syncing is in progress"); + } + subscriptions.push(onStateChange$.subscribe(), onStorageChange$.subscribe()); + }, + }; +} + +/** + * multiple different sync configs + * syncStates([ + * { + * storageKey: '_s1', + * stateStorage: stateStorage1, + * stateContainer: stateContainer1, + * }, + * { + * storageKey: '_s2', + * stateStorage: stateStorage2, + * stateContainer: stateContainer2, + * }, + * ]); + * @param stateSyncConfigs - Array of IStateSyncConfig to sync + */ +export function syncStates(stateSyncConfigs: Array>): ISyncStateRef { + const syncRefs = stateSyncConfigs.map(config => syncState(config)); + return { + stop: () => { + syncRefs.forEach(s => s.stop()); + }, + start: () => { + syncRefs.forEach(s => s.start()); + }, + }; +} diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts new file mode 100644 index 0000000000000..826122176e061 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts @@ -0,0 +1,120 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import '../../storage/hashed_item_store/mock'; +import { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage'; +import { History, createBrowserHistory } from 'history'; +import { takeUntil, toArray } from 'rxjs/operators'; +import { Subject } from 'rxjs'; + +describe('KbnUrlStateStorage', () => { + describe('useHash: false', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: false, history }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should flush state to url', () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.flush(); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should cancel url updates', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + const pr = urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + urlStateStorage.cancel(); + await pr; + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); + expect(urlStateStorage.get(key)).toEqual(null); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key) + .pipe(takeUntil(destroy$), toArray()) + .toPromise(); + + history.push(`/#?${key}=(ok:1,test:test)`); + history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test#?some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + }); + + describe('useHash: true', () => { + let urlStateStorage: IKbnUrlStateStorage; + let history: History; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(() => { + history = createBrowserHistory(); + history.push('/'); + urlStateStorage = createKbnUrlStateStorage({ useHash: true, history }); + }); + + it('should persist state to url', async () => { + const state = { test: 'test', ok: 1 }; + const key = '_s'; + await urlStateStorage.set(key, state); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=h@487e077"`); + expect(urlStateStorage.get(key)).toEqual(state); + }); + + it('should notify about url changes', async () => { + expect(urlStateStorage.change$).toBeDefined(); + const key = '_s'; + const destroy$ = new Subject(); + const result = urlStateStorage.change$!(key) + .pipe(takeUntil(destroy$), toArray()) + .toPromise(); + + history.push(`/#?${key}=(ok:1,test:test)`); + history.push(`/?query=test#?${key}=(ok:2,test:test)&some=test`); + history.push(`/?query=test#?some=test`); + + destroy$.next(); + destroy$.complete(); + + expect(await result).toEqual([{ test: 'test', ok: 1 }, { test: 'test', ok: 2 }, null]); + }); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts new file mode 100644 index 0000000000000..245006349ad55 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { History } from 'history'; +import { IStateStorage } from './types'; +import { + createKbnUrlControls, + getStateFromKbnUrl, + setStateToKbnUrl, +} from '../../state_management/url'; + +export interface IKbnUrlStateStorage extends IStateStorage { + set: (key: string, state: State, opts?: { replace: boolean }) => Promise; + get: (key: string) => State | null; + change$: (key: string) => Observable; + + // cancels any pending url updates + cancel: () => void; + + // synchronously runs any pending url updates + flush: (opts?: { replace?: boolean }) => void; +} + +/** + * Implements syncing to/from url strategies. + * Replicates what was implemented in state (AppState, GlobalState) + * Both expanded and hashed use cases + */ +export const createKbnUrlStateStorage = ( + { useHash = false, history }: { useHash: boolean; history?: History } = { useHash: false } +): IKbnUrlStateStorage => { + const url = createKbnUrlControls(history); + return { + set: ( + key: string, + state: State, + { replace = false }: { replace: boolean } = { replace: false } + ) => { + // syncState() utils doesn't wait for this promise + return url.updateAsync( + currentUrl => setStateToKbnUrl(key, state, { useHash }, currentUrl), + replace + ); + }, + get: key => getStateFromKbnUrl(key), + change$: (key: string) => + new Observable(observer => { + const unlisten = url.listen(() => { + observer.next(); + }); + + return () => { + unlisten(); + }; + }).pipe( + map(() => getStateFromKbnUrl(key)), + share() + ), + flush: ({ replace = false }: { replace?: boolean } = {}) => { + url.flush(replace); + }, + cancel() { + url.cancel(); + }, + }; +}; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts new file mode 100644 index 0000000000000..f69629e755008 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.test.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createSessionStorageStateStorage, + ISessionStorageStateStorage, +} from './create_session_storage_state_storage'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; + +describe('SessionStorageStateStorage', () => { + let browserStorage: StubBrowserStorage; + let stateStorage: ISessionStorageStateStorage; + beforeEach(() => { + browserStorage = new StubBrowserStorage(); + stateStorage = createSessionStorageStateStorage(browserStorage); + }); + + it('should synchronously sync to storage', () => { + const state = { state: 'state' }; + stateStorage.set('key', state); + expect(stateStorage.get('key')).toEqual(state); + expect(browserStorage.getItem('key')).not.toBeNull(); + }); + + it('should not implement change$', () => { + expect(stateStorage.change$).not.toBeDefined(); + }); +}); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts new file mode 100644 index 0000000000000..00edfdfd1ed61 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_session_storage_state_storage.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IStateStorage } from './types'; + +export interface ISessionStorageStateStorage extends IStateStorage { + set: (key: string, state: State) => void; + get: (key: string) => State | null; +} + +export const createSessionStorageStateStorage = ( + storage: Storage = window.sessionStorage +): ISessionStorageStateStorage => { + return { + set: (key: string, state: State) => storage.setItem(key, JSON.stringify(state)), + get: (key: string) => JSON.parse(storage.getItem(key)!), + }; +}; diff --git a/test/typings/encode_uri_query.d.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts similarity index 75% rename from test/typings/encode_uri_query.d.ts rename to src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts index 4bfc554624446..fe04333e5ef15 100644 --- a/test/typings/encode_uri_query.d.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/index.ts @@ -17,8 +17,9 @@ * under the License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { IStateStorage } from './types'; +export { createKbnUrlStateStorage, IKbnUrlStateStorage } from './create_kbn_url_state_storage'; +export { + createSessionStorageStateStorage, + ISessionStorageStateStorage, +} from './create_session_storage_state_storage'; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts new file mode 100644 index 0000000000000..add1dc259be45 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/types.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable } from 'rxjs'; + +/** + * Any StateStorage have to implement IStateStorage interface + * StateStorage is responsible for: + * * state serialisation / deserialization + * * persisting to and retrieving from storage + * + * For an example take a look at already implemented KbnUrl state storage + */ +export interface IStateStorage { + /** + * Take in a state object, should serialise and persist + */ + set: (key: string, state: State) => any; + + /** + * Should retrieve state from the storage and deserialize it + */ + get: (key: string) => State | null; + + /** + * Should notify when the stored state has changed + */ + change$?: (key: string) => Observable; + + /** + * Optional method to cancel any pending activity + * syncState() will call it, if it is provided by IStateStorage + */ + cancel?: () => void; +} diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts new file mode 100644 index 0000000000000..0f7395ad0f0e5 --- /dev/null +++ b/src/plugins/kibana_utils/public/state_sync/types.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BaseStateContainer } from '../state_containers/types'; +import { IStateStorage } from './state_sync_state_storage'; + +export interface INullableBaseStateContainer extends BaseStateContainer { + // State container for stateSync() have to accept "null" + // for example, set() implementation could handle null and fallback to some default state + // this is required to handle edge case, when state in storage becomes empty and syncing is in progress. + // state container will be notified about about storage becoming empty with null passed in + set: (state: State | null) => void; +} + +export interface IStateSyncConfig< + State = unknown, + StateStorage extends IStateStorage = IStateStorage +> { + /** + * Storage key to use for syncing, + * e.g. storageKey '_a' should sync state to ?_a query param + */ + storageKey: string; + /** + * State container to keep in sync with storage, have to implement INullableBaseStateContainer interface + * The idea is that ./state_containers/ should be used as a state container, + * but it is also possible to implement own custom container for advanced use cases + */ + stateContainer: INullableBaseStateContainer; + /** + * State storage to use, + * State storage is responsible for serialising / deserialising and persisting / retrieving stored state + * + * There are common strategies already implemented: + * './state_sync_state_storage/' + * which replicate what State (AppState, GlobalState) in legacy world did + * + */ + stateStorage: StateStorage; +} diff --git a/tasks/config/karma.js b/tasks/config/karma.js index c0d6074da61c5..0acd452530b30 100644 --- a/tasks/config/karma.js +++ b/tasks/config/karma.js @@ -20,6 +20,7 @@ import { dirname } from 'path'; import { times } from 'lodash'; import { makeJunitReportPath } from '@kbn/test'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; const TOTAL_CI_SHARDS = 4; const ROOT = dirname(require.resolve('../../package.json')); @@ -48,6 +49,25 @@ module.exports = function(grunt) { return ['progress']; } + function getKarmaFiles(shardNum) { + return [ + 'http://localhost:5610/test_bundle/built_css.css', + + `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.distFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', + + shardNum === undefined + ? `http://localhost:5610/bundles/tests.bundle.js` + : `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`, + + // this causes tilemap tests to fail, probably because the eui styles haven't been + // included in the karma harness a long some time, if ever + // `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', + 'http://localhost:5610/bundles/tests.style.css', + ]; + } + const config = { options: { // base path that will be used to resolve all patterns (eg. files, exclude) @@ -90,15 +110,7 @@ module.exports = function(grunt) { }, // list of files / patterns to load in the browser - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - 'http://localhost:5610/bundles/tests.bundle.js', - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(), proxies: { '/tests/': 'http://localhost:5610/tests/', @@ -181,15 +193,7 @@ module.exports = function(grunt) { config[`ciShard-${n}`] = { singleRun: true, options: { - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${n}`, - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(n), }, }; }); diff --git a/test/common/services/security/role_mappings.ts b/test/common/services/security/role_mappings.ts new file mode 100644 index 0000000000000..cc2fa23825498 --- /dev/null +++ b/test/common/services/security/role_mappings.ts @@ -0,0 +1,66 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import axios, { AxiosInstance } from 'axios'; +import util from 'util'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class RoleMappings { + private log: ToolingLog; + private axios: AxiosInstance; + + constructor(url: string, log: ToolingLog) { + this.log = log; + this.axios = axios.create({ + headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role_mappings' }, + baseURL: url, + maxRedirects: 0, + validateStatus: () => true, // we do our own validation below and throw better error messages + }); + } + + public async create(name: string, roleMapping: Record) { + this.log.debug(`creating role mapping ${name}`); + const { data, status, statusText } = await this.axios.post( + `/internal/security/role_mapping/${name}`, + roleMapping + ); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`created role mapping ${name}`); + } + + public async delete(name: string) { + this.log.debug(`deleting role mapping ${name}`); + const { data, status, statusText } = await this.axios.delete( + `/internal/security/role_mapping/${name}` + ); + if (status !== 200 && status !== 404) { + throw new Error( + `Expected status code of 200 or 404, received ${status} ${statusText}: ${util.inspect( + data + )}` + ); + } + this.log.debug(`deleted role mapping ${name}`); + } +} diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts index 6649a765a9e50..4eebb7b6697e0 100644 --- a/test/common/services/security/security.ts +++ b/test/common/services/security/security.ts @@ -21,6 +21,7 @@ import { format as formatUrl } from 'url'; import { Role } from './role'; import { User } from './user'; +import { RoleMappings } from './role_mappings'; import { FtrProviderContext } from '../../ftr_provider_context'; export function SecurityServiceProvider({ getService }: FtrProviderContext) { @@ -30,6 +31,7 @@ export function SecurityServiceProvider({ getService }: FtrProviderContext) { return new (class SecurityService { role = new Role(url, log); + roleMappings = new RoleMappings(url, log); user = new User(url, log); })(); } diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts index a4cd98b2a06ec..fe17532f6a41a 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -25,7 +25,7 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return new (class AppsMenu { /** - * Get the text and href from each of the links in the apps menu + * Get the attributes from each of the links in the apps menu */ public async readLinks() { const appMenu = await testSubjects.find('navDrawer'); @@ -37,12 +37,21 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return { text: $(link).text(), href: $(link).attr('href'), + disabled: $(link).attr('disabled') != null, }; }); return links; } + /** + * Get the attributes from the link with the given name. + * @param name + */ + public async getLink(name: string) { + return (await this.readLinks()).find(nl => nl.text === name); + } + /** * Determine if an app link with the given name exists * @param name diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json new file mode 100644 index 0000000000000..91d8e6fd8f9e1 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_app_status", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_app_status"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_app_status/package.json b/test/plugin_functional/plugins/core_app_status/package.json new file mode 100644 index 0000000000000..61655487c6acb --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_app_status", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_app_status", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_app_status/public/application.tsx b/test/plugin_functional/plugins/core_app_status/public/application.tsx new file mode 100644 index 0000000000000..323774392a6d7 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/application.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const AppStatusApp = () => ( + + + + + +

Welcome to App Status Test App!

+ + + + + + + +

App Status Test App home page section title

+
+
+
+ App Status Test App content +
+ + +); + +export const renderApp = (context: AppMountContext, { element }: AppMountParameters) => { + render(, element); + + return () => unmountComponentAtNode(element); +}; diff --git a/typings/encode_uri_query.d.ts b/test/plugin_functional/plugins/core_app_status/public/index.ts similarity index 74% rename from typings/encode_uri_query.d.ts rename to test/plugin_functional/plugins/core_app_status/public/index.ts index 4bfc554624446..e0ad7c25a54b8 100644 --- a/typings/encode_uri_query.d.ts +++ b/test/plugin_functional/plugins/core_app_status/public/index.ts @@ -17,8 +17,8 @@ * under the License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +import { PluginInitializer } from 'kibana/public'; +import { CoreAppStatusPlugin, CoreAppStatusPluginSetup, CoreAppStatusPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppStatusPlugin(); diff --git a/test/plugin_functional/plugins/core_app_status/public/plugin.tsx b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx new file mode 100644 index 0000000000000..85caaaf5f9090 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup, AppUpdater, AppUpdatableFields, CoreStart } from 'kibana/public'; +import { BehaviorSubject } from 'rxjs'; + +export class CoreAppStatusPlugin + implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'app_status', + title: 'App Status', + euiIconType: 'snowflake', + updater$: this.appUpdater, + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return { + setAppStatus: (status: Partial) => { + this.appUpdater.next(() => status); + }, + navigateToApp: async (appId: string) => { + return core.application.navigateToApp(appId); + }, + }; + } + public stop() {} +} + +export type CoreAppStatusPluginSetup = ReturnType; +export type CoreAppStatusPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json new file mode 100644 index 0000000000000..5fcaeafbb0d85 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts new file mode 100644 index 0000000000000..703ae30533bae --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, +} from '../../../../src/core/public/application/types'; +import { PluginFunctionalProviderContext } from '../../services'; +import { CoreAppStatusPluginStart } from '../../plugins/core_app_status/public/plugin'; +import '../../plugins/core_provider_plugin/types'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + + const setAppStatus = async (s: Partial) => { + await browser.executeAsync(async (status: Partial, cb: Function) => { + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + plugin.setAppStatus(status); + cb(); + }, s); + }; + + const navigateToApp = async (i: string): Promise<{ error?: string }> => { + return (await browser.executeAsync(async (appId, cb: Function) => { + // navigating in legacy mode performs a page refresh + // and webdriver seems to re-execute the script after the reload + // as it considers it didn't end on the previous session. + // however when testing navigation to NP app, __coreProvider is not accessible + // so we need to check for existence. + if (!window.__coreProvider) { + cb({}); + } + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + try { + await plugin.navigateToApp(appId); + cb({}); + } catch (e) { + cb({ + error: e.message, + }); + } + }, i)) as any; + }; + + describe('application status management', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('settings'); + }); + + it('can change the navLink status at runtime', async () => { + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.disabled, + }); + let link = await appsMenu.getLink('App Status'); + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(true); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.hidden, + }); + link = await appsMenu.getLink('App Status'); + expect(link).to.eql(undefined); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.visible, + tooltip: 'Some tooltip', + }); + link = await appsMenu.getLink('Some tooltip'); // the tooltip replaces the name in the selector we use. + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(false); + }); + + it('shows an error when navigating to an inaccessible app', async () => { + await setAppStatus({ + status: AppStatus.inaccessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.contain( + 'Trying to navigate to an inaccessible application: app_status' + ); + }); + + it('allows to navigate to an accessible app', async () => { + await setAppStatus({ + status: AppStatus.accessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.eql(undefined); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 6c55245d10f03..d66e2e7dc5da7 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -28,5 +28,6 @@ export default function({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./ui_settings')); loadTestFile(require.resolve('./top_nav')); loadTestFile(require.resolve('./application_leave_confirm')); + loadTestFile(require.resolve('./application_status')); }); } diff --git a/webpackShims/moment-timezone.js b/webpackShims/moment-timezone.js deleted file mode 100644 index d5e032ff21eef..0000000000000 --- a/webpackShims/moment-timezone.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -var moment = (module.exports = require('../node_modules/moment-timezone/moment-timezone')); -moment.tz.load(require('../node_modules/moment-timezone/data/packed/latest.json')); diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index 02904cc48e030..f38181ce56a2f 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -28,6 +28,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`, '^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`, '^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`, + '^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`, }, coverageDirectory: '/../target/kibana-coverage/jest', coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'], diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx index 8cdb7f050027d..0bd3896782603 100644 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx @@ -16,8 +16,8 @@ export const LicenseContext = React.createContext( export function LicenseProvider({ children }: { children: React.ReactChild }) { const { license$ } = useApmPluginContext().plugins.licensing; - const license = useObservable(license$); - const hasInvalidLicense = !license?.isActive; + const license = useObservable(license$, { isActive: true } as ILicense); + const hasInvalidLicense = !license.isActive; // if license is invalid show an error message if (hasInvalidLicense) { diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js deleted file mode 100644 index e397bda763f1a..0000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_colors_from_palette.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getColorsFromPalette } from '../../lib/get_colors_from_palette'; -import { - grayscalePalette, - gradientPalette, -} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; - -describe('getColorsFromPalette', () => { - it('returns the array of colors from a palette object when gradient is false', () => { - expect(getColorsFromPalette(grayscalePalette, 20)).to.eql(grayscalePalette.colors); - }); - - it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { - const result = getColorsFromPalette(gradientPalette, 16); - expect(result) - .to.have.length(16) - .and.to.eql([ - '#ffffff', - '#eeeeee', - '#dddddd', - '#cccccc', - '#bbbbbb', - '#aaaaaa', - '#999999', - '#888888', - '#777777', - '#666666', - '#555555', - '#444444', - '#333333', - '#222222', - '#111111', - '#000000', - ]); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js b/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js deleted file mode 100644 index ba43db7a83677..0000000000000 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_legend_config.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { getLegendConfig } from '../get_legend_config'; - -describe('getLegendConfig', () => { - describe('show', () => { - it('hides the legend', () => { - expect(getLegendConfig(false, 2)) - .to.only.have.key('show') - .and.to.have.property('show', false); - expect(getLegendConfig(false, 10)) - .to.only.have.key('show') - .and.to.have.property('show', false); - }); - - it('hides the legend when there are less than 2 series', () => { - expect(getLegendConfig(false, 1)) - .to.only.have.key('show') - .and.to.have.property('show', false); - expect(getLegendConfig(true, 1)) - .to.only.have.key('show') - .and.to.have.property('show', false); - }); - - it('shows the legend when there are two or more series', () => { - expect(getLegendConfig('sw', 2)).to.have.property('show', true); - expect(getLegendConfig(true, 5)).to.have.property('show', true); - }); - }); - - describe('position', () => { - it('sets the position of the legend', () => { - expect(getLegendConfig('nw')).to.have.property('position', 'nw'); - expect(getLegendConfig('ne')).to.have.property('position', 'ne'); - expect(getLegendConfig('sw')).to.have.property('position', 'sw'); - expect(getLegendConfig('se')).to.have.property('position', 'se'); - }); - - it("defaults to 'ne'", () => { - expect(getLegendConfig(true)).to.have.property('position', 'ne'); - expect(getLegendConfig('foo')).to.have.property('position', 'ne'); - }); - }); -}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts index 616e45c86c4af..88bb32c846c24 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/autocomplete.test.ts @@ -7,184 +7,195 @@ jest.mock('ui/new_platform'); import { functionSpecs } from '../../__tests__/fixtures/function_specs'; -import { getAutocompleteSuggestions } from './autocomplete'; - -describe('getAutocompleteSuggestions', () => { - it('should suggest functions', () => { - const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); - expect(suggestions.length).toBe(functionSpecs.length); - expect(suggestions[0].start).toBe(0); - expect(suggestions[0].end).toBe(0); - }); - - it('should suggest functions filtered by text', () => { - const expression = 'pl'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); - const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); - expect(nonmatching.length).toBe(0); - expect(suggestions[0].start).toBe(0); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest arguments', () => { - const expression = 'plot '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const plotFn = functionSpecs.find(spec => spec.name === 'plot'); - expect(suggestions.length).toBe(Object.keys(plotFn.args).length); - expect(suggestions[0].start).toBe(expression.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest arguments filtered by text', () => { - const expression = 'plot axis'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const plotFn = functionSpecs.find(spec => spec.name === 'plot'); - const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); - expect(suggestions.length).toBe(matchingArgs.length); - expect(suggestions[0].start).toBe('plot '.length); - expect(suggestions[0].end).toBe('plot axis'.length); - }); - - it('should suggest values', () => { - const expression = 'shape shape='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - expect(suggestions.length).toBe(shapeFn.args.shape.options.length); - expect(suggestions[0].start).toBe(expression.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest values filtered by text', () => { - const expression = 'shape shape=ar'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); - expect(suggestions.length).toBe(matchingValues.length); - expect(suggestions[0].start).toBe(expression.length - 'ar'.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should suggest functions inside an expression', () => { - const expression = 'if {}'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - expect(suggestions.length).toBe(functionSpecs.length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest arguments inside an expression', () => { - const expression = 'if {lt }'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const ltFn = functionSpecs.find(spec => spec.name === 'lt'); - expect(suggestions.length).toBe(Object.keys(ltFn.args).length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest values inside an expression', () => { - const expression = 'if {shape shape=}'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - expect(suggestions.length).toBe(shapeFn.args.shape.options.length); - expect(suggestions[0].start).toBe(expression.length - 1); - expect(suggestions[0].end).toBe(expression.length - 1); - }); - - it('should suggest values inside quotes', () => { - const expression = 'shape shape="ar"'; - const suggestions = getAutocompleteSuggestions( - functionSpecs, - expression, - expression.length - 1 - ); - const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); - const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); - expect(suggestions.length).toBe(matchingValues.length); - expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); - expect(suggestions[0].end).toBe(expression.length); - }); - - it('should prioritize functions that start with text', () => { - const expression = 't'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); - const alterColumnIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('alterColumn') - ); - expect(tableIndex).toBeLessThan(alterColumnIndex); - }); - - it('should prioritize functions that match the previous function type', () => { - const expression = 'plot | '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); - const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); - expect(renderIndex).toBeLessThan(anyIndex); - }); - - it('should alphabetize functions', () => { - const expression = ''; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); - const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); - expect(anyIndex).toBeLessThan(metricIndex); - }); - - it('should prioritize arguments that start with text', () => { - const expression = 'plot y'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); - const defaultStyleIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('defaultStyle') - ); - expect(yaxisIndex).toBeLessThan(defaultStyleIndex); - }); - - it('should prioritize unnamed arguments', () => { - const expression = 'case '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); - const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); - expect(whenIndex).toBeLessThan(thenIndex); - }); - - it('should alphabetize arguments', () => { - const expression = 'plot '; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); - const defaultStyleIndex = suggestions.findIndex(suggestion => - suggestion.text.includes('defaultStyle') - ); - expect(defaultStyleIndex).toBeLessThan(yaxisIndex); - }); - - it('should quote string values', () => { - const expression = 'shape shape='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toMatch(/^".*"$/); - }); - - it('should not quote sub expression value suggestions', () => { - const expression = 'plot font='; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toBe('{font}'); - }); - - it('should not quote booleans', () => { - const expression = 'table paginate=true'; - const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); - expect(suggestions[0].text.trim()).toBe('true'); +import { getAutocompleteSuggestions, getFnArgDefAtPosition } from './autocomplete'; + +describe('autocomplete', () => { + describe('getFnArgDefAtPosition', () => { + it('should return function definition for plot', () => { + const expression = 'plot '; + const def = getFnArgDefAtPosition(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(def.fnDef).toBe(plotFn); + }); + }); + + describe('getAutocompleteSuggestions', () => { + it('should suggest functions', () => { + const suggestions = getAutocompleteSuggestions(functionSpecs, '', 0); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(0); + }); + + it('should suggest functions filtered by text', () => { + const expression = 'pl'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, 0); + const nonmatching = suggestions.map(s => s.text).filter(text => !text.includes(expression)); + expect(nonmatching.length).toBe(0); + expect(suggestions[0].start).toBe(0); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + expect(suggestions.length).toBe(Object.keys(plotFn.args).length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest arguments filtered by text', () => { + const expression = 'plot axis'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const plotFn = functionSpecs.find(spec => spec.name === 'plot'); + const matchingArgs = Object.keys(plotFn.args).filter(key => key.includes('axis')); + expect(suggestions.length).toBe(matchingArgs.length); + expect(suggestions[0].start).toBe('plot '.length); + expect(suggestions[0].end).toBe('plot axis'.length); + }); + + it('should suggest values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest values filtered by text', () => { + const expression = 'shape shape=ar'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - 'ar'.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should suggest functions inside an expression', () => { + const expression = 'if {}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + expect(suggestions.length).toBe(functionSpecs.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest arguments inside an expression', () => { + const expression = 'if {lt }'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const ltFn = functionSpecs.find(spec => spec.name === 'lt'); + expect(suggestions.length).toBe(Object.keys(ltFn.args).length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest values inside an expression', () => { + const expression = 'if {shape shape=}'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + expect(suggestions.length).toBe(shapeFn.args.shape.options.length); + expect(suggestions[0].start).toBe(expression.length - 1); + expect(suggestions[0].end).toBe(expression.length - 1); + }); + + it('should suggest values inside quotes', () => { + const expression = 'shape shape="ar"'; + const suggestions = getAutocompleteSuggestions( + functionSpecs, + expression, + expression.length - 1 + ); + const shapeFn = functionSpecs.find(spec => spec.name === 'shape'); + const matchingValues = shapeFn.args.shape.options.filter((key: string) => key.includes('ar')); + expect(suggestions.length).toBe(matchingValues.length); + expect(suggestions[0].start).toBe(expression.length - '"ar"'.length); + expect(suggestions[0].end).toBe(expression.length); + }); + + it('should prioritize functions that start with text', () => { + const expression = 't'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const tableIndex = suggestions.findIndex(suggestion => suggestion.text.includes('table')); + const alterColumnIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('alterColumn') + ); + expect(tableIndex).toBeLessThan(alterColumnIndex); + }); + + it('should prioritize functions that match the previous function type', () => { + const expression = 'plot | '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const renderIndex = suggestions.findIndex(suggestion => suggestion.text.includes('render')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(renderIndex).toBeLessThan(anyIndex); + }); + + it('should alphabetize functions', () => { + const expression = ''; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const metricIndex = suggestions.findIndex(suggestion => suggestion.text.includes('metric')); + const anyIndex = suggestions.findIndex(suggestion => suggestion.text.includes('any')); + expect(anyIndex).toBeLessThan(metricIndex); + }); + + it('should prioritize arguments that start with text', () => { + const expression = 'plot y'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(yaxisIndex).toBeLessThan(defaultStyleIndex); + }); + + it('should prioritize unnamed arguments', () => { + const expression = 'case '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const whenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('when')); + const thenIndex = suggestions.findIndex(suggestion => suggestion.text.includes('then')); + expect(whenIndex).toBeLessThan(thenIndex); + }); + + it('should alphabetize arguments', () => { + const expression = 'plot '; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + const yaxisIndex = suggestions.findIndex(suggestion => suggestion.text.includes('yaxis')); + const defaultStyleIndex = suggestions.findIndex(suggestion => + suggestion.text.includes('defaultStyle') + ); + expect(defaultStyleIndex).toBeLessThan(yaxisIndex); + }); + + it('should quote string values', () => { + const expression = 'shape shape='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toMatch(/^".*"$/); + }); + + it('should not quote sub expression value suggestions', () => { + const expression = 'plot font='; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toBe('{font}'); + }); + + it('should not quote booleans', () => { + const expression = 'table paginate=true'; + const suggestions = getAutocompleteSuggestions(functionSpecs, expression, expression.length); + expect(suggestions[0].text.trim()).toBe('true'); + }); }); }); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts similarity index 77% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts index acd9e6d1821d6..8bfe723bc2ae0 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/dataurl.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/dataurl.test.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isValidDataUrl, parseDataUrl } from '../dataurl'; +import { isValidDataUrl, parseDataUrl } from './dataurl'; const BASE64_TEXT = 'data:text/plain;charset=utf-8;base64,VGhpcyBpcyBhIHRlc3Q='; const BASE64_SVG = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='; const BASE64_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk8PxfDwADYgHJvQ16TAAAAABJRU5ErkJggg=='; +const INVALID_BASE64_PIXEL = + 'data:image/png;%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%01%00%00%00%01%08%06%00%00%00%1F%15%C4%89%0'; const RAW_TEXT = 'data:text/plain;charset=utf-8,This%20is%20a%20test'; const RAW_SVG = @@ -20,10 +22,13 @@ const RAW_PIXEL = describe('dataurl', () => { describe('isValidDataUrl', () => { - test('invalid data url', () => { + it('returns false for an invalid data url', () => { expect(isValidDataUrl('somestring')).toBe(false); }); - test('valid data urls', () => { + it('returns false for an empty string', () => { + expect(isValidDataUrl('')).toBe(false); + }); + it('returns true for valid data urls', () => { expect(isValidDataUrl(BASE64_TEXT)).toBe(true); expect(isValidDataUrl(BASE64_SVG)).toBe(true); expect(isValidDataUrl(BASE64_PIXEL)).toBe(true); @@ -34,10 +39,13 @@ describe('dataurl', () => { }); describe('dataurl.parseDataUrl', () => { - test('invalid data url', () => { + it('returns null for an invalid data url', () => { expect(parseDataUrl('somestring')).toBeNull(); }); - test('text data urls', () => { + it('returns null for an invalid base64 image', () => { + expect(parseDataUrl(INVALID_BASE64_PIXEL)).toBeNull(); + }); + it('returns correct values for text data urls', () => { expect(parseDataUrl(BASE64_TEXT)).toEqual({ charset: 'utf-8', data: null, @@ -55,7 +63,7 @@ describe('dataurl', () => { mimetype: 'text/plain', }); }); - test('png data urls', () => { + it('returns correct values for png data urls', () => { expect(parseDataUrl(RAW_PIXEL)).toBeNull(); expect(parseDataUrl(BASE64_PIXEL)).toEqual({ charset: undefined, @@ -66,7 +74,7 @@ describe('dataurl', () => { mimetype: 'image/png', }); }); - test('svg data urls', () => { + it('returns correct values for svg data urls', () => { expect(parseDataUrl(RAW_SVG)).toEqual({ charset: undefined, data: null, diff --git a/x-pack/legacy/plugins/canvas/common/lib/errors.test.js b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js new file mode 100644 index 0000000000000..a589fde5dadb6 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/errors.test.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RenderError } from './errors'; + +describe('errors', () => { + it('creates a test error', () => { + // eslint-disable-next-line new-cap + const throwTestError = () => RenderError(); + expect(throwTestError.name).toBe('throwTestError'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js new file mode 100644 index 0000000000000..ae46661b50cd2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/expression_form_handlers.test.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFormHandlers } from './expression_form_handlers'; + +describe('ExpressionFormHandlers', () => { + it('executes destroy function', () => { + const handler = new ExpressionFormHandlers(); + handler.onDestroy(() => { + return 'DESTROYED!'; + }); + expect(handler.destroy()).toBe('DESTROYED!'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts new file mode 100644 index 0000000000000..d06c2af4f062a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/fetch.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fetch, arrayBufferFetch } from './fetch'; + +describe('fetch', () => { + it('test fetch headers', () => { + expect(fetch.defaults.headers.Accept).toBe('application/json'); + expect(fetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(fetch.defaults.headers['kbn-xsrf']).toBe('professionally-crafted-string-of-text'); + }); + + it('test arrayBufferFetch headers', () => { + expect(arrayBufferFetch.defaults.headers.Accept).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['Content-Type']).toBe('application/json'); + expect(arrayBufferFetch.defaults.headers['kbn-xsrf']).toBe( + 'professionally-crafted-string-of-text' + ); + expect(arrayBufferFetch.defaults.responseType).toBe('arraybuffer'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js new file mode 100644 index 0000000000000..ebc72db1f67f0 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/get_colors_from_palette.test.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + grayscalePalette, + gradientPalette, +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_styles'; +import { getColorsFromPalette } from './get_colors_from_palette'; + +describe('getColorsFromPalette', () => { + it('returns the array of colors from a palette object when gradient is false', () => { + expect(getColorsFromPalette(grayscalePalette, 20)).toBe(grayscalePalette.colors); + }); + + it('returns an array of colors with equidistant colors with length equal to the number of series when gradient is true', () => { + const result = getColorsFromPalette(gradientPalette, 16); + expect(result).toEqual([ + '#ffffff', + '#eeeeee', + '#dddddd', + '#cccccc', + '#bbbbbb', + '#aaaaaa', + '#999999', + '#888888', + '#777777', + '#666666', + '#555555', + '#444444', + '#333333', + '#222222', + '#111111', + '#000000', + ]); + expect(result).toHaveLength(16); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts similarity index 87% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts index 34cfbb5a2befb..82e724c33ecc8 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/get_field_type.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/get_field_type.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getFieldType } from '../get_field_type'; import { emptyTable, testTable, -} from '../../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +} from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +import { getFieldType } from './get_field_type'; describe('getFieldType', () => { it('returns type of a field in a datatable', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js new file mode 100644 index 0000000000000..b9ab9ae6aba54 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/get_legend_config.test.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getLegendConfig } from './get_legend_config'; + +describe('getLegendConfig', () => { + describe('show', () => { + it('hides the legend', () => { + expect(getLegendConfig(false, 2)).toHaveProperty('show', false); + expect(getLegendConfig(false, 10)).toHaveProperty('show', false); + }); + + it('hides the legend when there are less than 2 series', () => { + expect(getLegendConfig(false, 1)).toHaveProperty('show', false); + expect(getLegendConfig(true, 1)).toHaveProperty('show', false); + }); + + it('shows the legend when there are two or more series', () => { + expect(getLegendConfig('sw', 2)).toHaveProperty('show', true); + expect(getLegendConfig(true, 5)).toHaveProperty('show', true); + }); + }); + + describe('position', () => { + it('sets the position of the legend', () => { + expect(getLegendConfig('nw')).toHaveProperty('position', 'nw'); + expect(getLegendConfig('ne')).toHaveProperty('position', 'ne'); + expect(getLegendConfig('sw')).toHaveProperty('position', 'sw'); + expect(getLegendConfig('se')).toHaveProperty('position', 'se'); + }); + + it("defaults to 'ne'", () => { + expect(getLegendConfig(true)).toHaveProperty('position', 'ne'); + expect(getLegendConfig('foo')).toHaveProperty('position', 'ne'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js new file mode 100644 index 0000000000000..5fcb2d42395fa --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/handlebars.test.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { testTable } from '../../canvas_plugin_src/functions/common/__tests__/fixtures/test_tables'; +import { Handlebars } from './handlebars'; + +describe('handlebars', () => { + it('registers math function and returns argument error', () => { + const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}"); + expect(template()).toBe('test math: MATH ERROR: first argument must be an array'); + }); + it('evaluates math function successfully', () => { + const template = Handlebars.compile("test math: {{math rows 'mean(price * quantity)' 2}}"); + expect(template(testTable)).toBe('test math: 82164.33'); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts similarity index 80% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts index d9aa56314948e..00b4b40fa9839 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/hex_to_rgb.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/hex_to_rgb.test.ts @@ -4,21 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hexToRgb } from '../hex_to_rgb'; +import { hexToRgb } from './hex_to_rgb'; describe('hexToRgb', () => { - test('invalid hex', () => { + it('returns null for an invalid hex', () => { expect(hexToRgb('hexadecimal')).toBeNull(); expect(hexToRgb('#00')).toBeNull(); expect(hexToRgb('#00000')).toBeNull(); }); - test('shorthand', () => { + it('returns correct value for shorthand hex codes', () => { expect(hexToRgb('#000')).toEqual([0, 0, 0]); expect(hexToRgb('#FFF')).toEqual([255, 255, 255]); expect(hexToRgb('#fff')).toEqual([255, 255, 255]); expect(hexToRgb('#fFf')).toEqual([255, 255, 255]); }); - test('longhand', () => { + it('returns correct value for longhand hex codes', () => { expect(hexToRgb('#000000')).toEqual([0, 0, 0]); expect(hexToRgb('#ffffff')).toEqual([255, 255, 255]); expect(hexToRgb('#fffFFF')).toEqual([255, 255, 255]); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts similarity index 97% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts index 2a7cef7cf4236..65bc2469647aa 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/httpurl.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/httpurl.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isValidHttpUrl } from '../httpurl'; +import { isValidHttpUrl } from './httpurl'; describe('httpurl.isValidHttpUrl', () => { it('matches HTTP URLs', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts similarity index 97% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts index 6f6d42e7129a9..faf319769cab0 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/pivot_object_array.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/pivot_object_array.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pivotObjectArray } from '../pivot_object_array'; +import { pivotObjectArray } from './pivot_object_array'; interface Car { make: string; diff --git a/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js new file mode 100644 index 0000000000000..bbbd4f51d483f --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/resolve_dataurl.test.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { missingImage } from '../../common/lib/missing_asset'; +import { resolveFromArgs, resolveWithMissingImage } from './resolve_dataurl'; + +describe('resolve_dataurl', () => { + describe('resolveFromArgs', () => { + it('finds and returns the dataurl from args successfully', () => { + const args = { + name: 'dataurl', + argType: 'imageUpload', + dataurl: [missingImage, 'test2'], + }; + expect(resolveFromArgs(args)).toBe(missingImage); + }); + it('finds and returns null for invalid dataurl', () => { + const args = { + name: 'dataurl', + argType: 'imageUpload', + dataurl: ['invalid url', 'test2'], + }; + expect(resolveFromArgs(args)).toBe(null); + }); + }); + + describe('resolveWithMissingImage', () => { + it('returns valid dataurl', () => { + expect(resolveWithMissingImage(missingImage)).toBe(missingImage); + }); + it('returns missingImage for invalid dataurl', () => { + expect(resolveWithMissingImage('invalid dataurl')).toBe(missingImage); + }); + it('returns null for null dataurl', () => { + expect(resolveWithMissingImage(null)).toBe(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts similarity index 94% rename from x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts rename to x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts index e67e46f9e5dac..d6eeb9392f40f 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/__tests__/unquote_string.test.ts +++ b/x-pack/legacy/plugins/canvas/common/lib/unquote_string.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { unquoteString } from '../unquote_string'; +import { unquoteString } from './unquote_string'; describe('unquoteString', () => { it('removes double quotes', () => { diff --git a/x-pack/legacy/plugins/canvas/common/lib/url.test.js b/x-pack/legacy/plugins/canvas/common/lib/url.test.js new file mode 100644 index 0000000000000..d49d12c6bf382 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/common/lib/url.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { missingImage } from '../../common/lib/missing_asset'; +import { isValidUrl } from './url'; + +describe('resolve_dataurl', () => { + it('returns valid dataurl', () => { + expect(isValidUrl(missingImage)).toBe(true); + }); + it('returns valid http url', () => { + const httpurl = 'https://test.com/s/'; + expect(isValidUrl(httpurl)).toBe(true); + }); + it('returns false for invalid url', () => { + expect(isValidUrl('test')).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/webpackShims/moment.js b/x-pack/legacy/plugins/canvas/webpackShims/moment.js deleted file mode 100644 index 1261aa7f7bd0f..0000000000000 --- a/x-pack/legacy/plugins/canvas/webpackShims/moment.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -module.exports = require('../../../node_modules/moment/min/moment.min.js'); diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx index 2f38f68b01f16..d7cbcf59213be 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_manager.test.tsx @@ -33,6 +33,7 @@ describe('field_manager', () => { selected: true, type: 'string', hopSize: 5, + aggregatable: true, }, { name: 'field2', @@ -42,6 +43,7 @@ describe('field_manager', () => { type: 'string', hopSize: 0, lastValidHopSize: 5, + aggregatable: false, }, { name: 'field3', @@ -50,6 +52,16 @@ describe('field_manager', () => { selected: false, type: 'string', hopSize: 5, + aggregatable: true, + }, + { + name: 'field4', + color: 'orange', + icon: getSuitableIcon('field4'), + selected: false, + type: 'string', + hopSize: 5, + aggregatable: false, }, ]) ); @@ -86,6 +98,17 @@ describe('field_manager', () => { ).toEqual('field2'); }); + it('should show selected non-aggregatable fields in picker, but hide unselected ones', () => { + expect( + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('options') + .map((option: { label: string }) => option.label) + ).toEqual(['field1', 'field2', 'field3']); + }); + it('should select fields from picker', () => { expect( getInstance() @@ -130,6 +153,25 @@ describe('field_manager', () => { expect(getInstance().find(FieldEditor).length).toEqual(1); }); + it('should show remove non-aggregatable fields from picker after deselection', () => { + act(() => { + getInstance() + .find(FieldEditor) + .at(1) + .dive() + .find(EuiContextMenu) + .prop('panels')![0].items![2].onClick!({} as any); + }); + expect( + getInstance() + .find(FieldPicker) + .dive() + .find(EuiSelectable) + .prop('options') + .map((option: { label: string }) => option.label) + ).toEqual(['field1', 'field3']); + }); + it('should disable field', () => { const toggleItem = getInstance() .find(FieldEditor) diff --git a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx index 6ad792defb669..b38e3f8430980 100644 --- a/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx +++ b/x-pack/legacy/plugins/graph/public/components/field_manager/field_picker.tsx @@ -114,9 +114,26 @@ export function FieldPicker({ function toOptions( fields: WorkspaceField[] ): Array<{ label: string; checked?: 'on' | 'off'; prepend?: ReactNode }> { - return fields.map(field => ({ - label: field.name, - prepend: , - checked: field.selected ? 'on' : undefined, - })); + return ( + fields + // don't show non-aggregatable fields, except for the case when they are already selected. + // this is necessary to ensure backwards compatibility with existing workspaces that might + // contain non-aggregatable fields. + .filter(field => isExplorable(field) || field.selected) + .map(field => ({ + label: field.name, + prepend: , + checked: field.selected ? 'on' : undefined, + })) + ); +} + +const explorableTypes = ['string', 'number', 'date', 'ip', 'boolean']; + +function isExplorable(field: WorkspaceField) { + if (!field.aggregatable) { + return false; + } + + return explorableTypes.includes(field.type); } diff --git a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx index a615901f40e25..0109e1f5a5ac7 100644 --- a/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/legacy/plugins/graph/public/components/settings/settings.test.tsx @@ -112,6 +112,7 @@ describe('settings', () => { code: '1', label: 'test', }, + aggregatable: true, }, { selected: false, @@ -123,6 +124,7 @@ describe('settings', () => { code: '1', label: 'test', }, + aggregatable: true, }, ]) ); diff --git a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts index 3bfc868fcb06e..79ff4debc7e82 100644 --- a/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/fetch_top_nodes.test.ts @@ -13,8 +13,24 @@ describe('fetch_top_nodes', () => { it('should build terms agg', async () => { const postMock = jest.fn(() => Promise.resolve({ resp: {} })); await fetchTopNodes(postMock as any, 'test', [ - { color: '', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, - { color: '', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + { + color: '', + hopSize: 5, + icon, + name: 'field1', + selected: false, + type: 'string', + aggregatable: true, + }, + { + color: '', + hopSize: 5, + icon, + name: 'field2', + selected: false, + type: 'string', + aggregatable: true, + }, ]); expect(postMock).toHaveBeenCalledWith('../api/graph/searchProxy', { body: JSON.stringify({ @@ -65,8 +81,24 @@ describe('fetch_top_nodes', () => { }) ); const result = await fetchTopNodes(postMock as any, 'test', [ - { color: 'red', hopSize: 5, icon, name: 'field1', selected: false, type: 'string' }, - { color: 'blue', hopSize: 5, icon, name: 'field2', selected: false, type: 'string' }, + { + color: 'red', + hopSize: 5, + icon, + name: 'field1', + selected: false, + type: 'string', + aggregatable: true, + }, + { + color: 'blue', + hopSize: 5, + icon, + name: 'field2', + selected: false, + type: 'string', + aggregatable: true, + }, ]); expect(result.length).toEqual(4); expect(result[0]).toEqual({ diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts index d38c950a5986f..1861479f85f18 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.test.ts @@ -119,9 +119,9 @@ describe('deserialize', () => { savedWorkspace, { getNonScriptedFields: () => [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'string' }, - { name: 'field3', type: 'string' }, + { name: 'field1', type: 'string', aggregatable: true }, + { name: 'field2', type: 'string', aggregatable: true }, + { name: 'field3', type: 'string', aggregatable: true }, ], } as IndexPattern, workspace @@ -140,6 +140,7 @@ describe('deserialize', () => { expect(allFields).toMatchInlineSnapshot(` Array [ Object { + "aggregatable": true, "color": "black", "hopSize": undefined, "icon": undefined, @@ -149,6 +150,7 @@ describe('deserialize', () => { "type": "string", }, Object { + "aggregatable": true, "color": "black", "hopSize": undefined, "icon": undefined, @@ -158,6 +160,7 @@ describe('deserialize', () => { "type": "string", }, Object { + "aggregatable": true, "color": "#CE0060", "hopSize": 5, "icon": Object { diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts index af34b4f1a725b..43425077cc174 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/deserialize.ts @@ -89,6 +89,7 @@ export function mapFields(indexPattern: IndexPattern): WorkspaceField[] { color: colorChoices[index % colorChoices.length], selected: false, type: field.type, + aggregatable: Boolean(field.aggregatable), })) .sort((a, b) => { if (a.name < b.name) { diff --git a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts index 0e0c750383a71..a3942eccfdac3 100644 --- a/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/legacy/plugins/graph/public/services/persistence/serialize.test.ts @@ -41,6 +41,7 @@ describe('serialize', () => { name: 'field1', selected: true, type: 'string', + aggregatable: true, }, { color: 'black', @@ -48,6 +49,7 @@ describe('serialize', () => { name: 'field2', selected: true, type: 'string', + aggregatable: true, }, ], selectedIndex: { diff --git a/x-pack/legacy/plugins/graph/public/types/app_state.ts b/x-pack/legacy/plugins/graph/public/types/app_state.ts index eef8060f07f5c..876f2cf23b53a 100644 --- a/x-pack/legacy/plugins/graph/public/types/app_state.ts +++ b/x-pack/legacy/plugins/graph/public/types/app_state.ts @@ -25,6 +25,7 @@ export interface WorkspaceField { icon: FontawesomeIcon; selected: boolean; type: string; + aggregatable: boolean; } export interface AdvancedSettings { diff --git a/x-pack/legacy/plugins/graph/public/types/persistence.ts b/x-pack/legacy/plugins/graph/public/types/persistence.ts index 7fc5e15d9ea72..adb07605b61c4 100644 --- a/x-pack/legacy/plugins/graph/public/types/persistence.ts +++ b/x-pack/legacy/plugins/graph/public/types/persistence.ts @@ -37,7 +37,7 @@ export interface SerializedUrlTemplate extends Omit { +export interface SerializedField extends Omit { iconClass: string; } diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 9463eccb93a02..2c0ea7fe699b8 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -20,8 +20,8 @@ import sinon from 'sinon'; import { findTestSubject } from '@elastic/eui/lib/test'; import { positiveNumbersAboveZeroErrorMessage, - numberRequiredMessage, positiveNumberRequiredMessage, + numberRequiredMessage, maximumAgeRequiredMessage, maximumSizeRequiredMessage, policyNameRequiredMessage, @@ -243,17 +243,18 @@ describe('edit policy', () => { noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', ''); save(rendered); expectedErrorMessages(rendered, [numberRequiredMessage]); }); - test('should show positive number required above zero error when trying to save warm phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); setPhaseAfter(rendered, 'warm', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save warm phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -383,14 +384,14 @@ describe('edit policy', () => { }); }); describe('cold phase', () => { - test('should show positive number required error when trying to save cold phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'cold'); setPhaseAfter(rendered, 'cold', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save cold phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -464,14 +465,14 @@ describe('edit policy', () => { }); }); describe('delete phase', () => { - test('should show positive number required error when trying to save delete phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'delete'); setPhaseAfter(rendered, 'delete', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', () => { const rendered = mountWithIntl(component); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js index 0ed28bbaa905f..b4c9f4e958cd2 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js @@ -131,7 +131,7 @@ export const MinAgeInput = props => { onChange={async e => { setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); }} - min={1} + min={0} /> diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js index b0af0e6547803..a8f7fd3f4bdfa 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js @@ -17,7 +17,7 @@ import { export const defaultColdPhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_REPLICA_COUNT]: '', diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js index 5a44539ff90f8..b5296cd83fabd 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js @@ -15,7 +15,7 @@ export const defaultDeletePhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', }; export const defaultEmptyDeletePhase = defaultDeletePhase; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js index d3dc55178b253..f02ac2096675f 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js @@ -23,7 +23,7 @@ export const defaultWarmPhase = { [PHASE_ROLLOVER_ALIAS]: '', [PHASE_FORCE_MERGE_SEGMENTS]: '', [PHASE_FORCE_MERGE_ENABLED]: false, - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_SHRINK_ENABLED]: false, diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js index 026845c78ee66..750a7feb19c3d 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js @@ -120,12 +120,6 @@ export const validatePhase = (type, phase, errors) => { phaseErrors[numberedAttribute] = [numberRequiredMessage]; } else if (phase[numberedAttribute] < 0) { phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; - } else if ( - (numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE || - numberedAttribute === PHASE_PRIMARY_SHARD_COUNT) && - phase[numberedAttribute] < 1 - ) { - phaseErrors[numberedAttribute] = [positiveNumbersAboveZeroErrorMessage]; } } } diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.js b/x-pack/legacy/plugins/maps/public/actions/map_actions.js index 053b389a59011..f05b7eba9e7e0 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.js +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.js @@ -39,7 +39,7 @@ export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS'; export const ADD_WAITING_FOR_MAP_READY_LAYER = 'ADD_WAITING_FOR_MAP_READY_LAYER'; export const CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST = 'CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST'; export const REMOVE_LAYER = 'REMOVE_LAYER'; -export const TOGGLE_LAYER_VISIBLE = 'TOGGLE_LAYER_VISIBLE'; +export const SET_LAYER_VISIBILITY = 'SET_LAYER_VISIBILITY'; export const MAP_EXTENT_CHANGED = 'MAP_EXTENT_CHANGED'; export const MAP_READY = 'MAP_READY'; export const MAP_DESTROYED = 'MAP_DESTROYED'; @@ -72,6 +72,7 @@ export const DISABLE_TOOLTIP_CONTROL = 'DISABLE_TOOLTIP_CONTROL'; export const HIDE_TOOLBAR_OVERLAY = 'HIDE_TOOLBAR_OVERLAY'; export const HIDE_LAYER_CONTROL = 'HIDE_LAYER_CONTROL'; export const HIDE_VIEW_CONTROL = 'HIDE_VIEW_CONTROL'; +export const SET_WAITING_FOR_READY_HIDDEN_LAYERS = 'SET_WAITING_FOR_READY_HIDDEN_LAYERS'; function getLayerLoadingCallbacks(dispatch, layerId) { return { @@ -252,23 +253,25 @@ export function cleanTooltipStateForLayer(layerId, layerFeatures = []) { }; } -export function toggleLayerVisible(layerId) { +export function setLayerVisibility(layerId, makeVisible) { return async (dispatch, getState) => { //if the current-state is invisible, we also want to sync data //e.g. if a layer was invisible at start-up, it won't have any data loaded const layer = getLayerById(layerId, getState()); - if (!layer) { + + // If the layer visibility is already what we want it to be, do nothing + if (!layer || layer.isVisible() === makeVisible) { return; } - const makeVisible = !layer.isVisible(); if (!makeVisible) { dispatch(cleanTooltipStateForLayer(layerId)); } await dispatch({ - type: TOGGLE_LAYER_VISIBLE, + type: SET_LAYER_VISIBILITY, layerId, + visibility: makeVisible, }); if (makeVisible) { dispatch(syncDataForLayer(layerId)); @@ -276,6 +279,18 @@ export function toggleLayerVisible(layerId) { }; } +export function toggleLayerVisible(layerId) { + return async (dispatch, getState) => { + const layer = getLayerById(layerId, getState()); + if (!layer) { + return; + } + const makeVisible = !layer.isVisible(); + + dispatch(setLayerVisibility(layerId, makeVisible)); + }; +} + export function setSelectedLayer(layerId) { return async (dispatch, getState) => { const oldSelectedLayer = getSelectedLayerId(getState()); @@ -840,3 +855,17 @@ export function hideLayerControl() { export function hideViewControl() { return { type: HIDE_VIEW_CONTROL, hideViewControl: true }; } + +export function setHiddenLayers(hiddenLayerIds) { + return (dispatch, getState) => { + const isMapReady = getMapReady(getState()); + + if (!isMapReady) { + dispatch({ type: SET_WAITING_FOR_READY_HIDDEN_LAYERS, hiddenLayerIds }); + } else { + getLayerListRaw(getState()).forEach(layer => + dispatch(setLayerVisibility(layer.id, !hiddenLayerIds.includes(layer.id))) + ); + } + }; +} diff --git a/x-pack/legacy/plugins/maps/public/embeddable/README.md b/x-pack/legacy/plugins/maps/public/embeddable/README.md index eb6571a96016c..1de327702fb87 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/README.md +++ b/x-pack/legacy/plugins/maps/public/embeddable/README.md @@ -9,6 +9,7 @@ - **hideToolbarOverlay:** (Boolean) Will disable toolbar, which can be used to navigate to coordinate by entering lat/long and zoom values. - **hideLayerControl:** (Boolean) Will hide useful layer control, which can be used to hide/show a layer to get a refined view of the map. - **hideViewControl:** (Boolean) Will hide view control at bottom right of the map, which shows lat/lon values based on mouse hover in the map, this is useful to get coordinate value from a particular point in map. +- **hiddenLayers:** (Array of Strings) Array of layer ids that should be hidden. Any other layers will be set to visible regardless of their value in the layerList used to initialize the embeddable ### Creating a Map embeddable from saved object ``` diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js index 2ee766f91fbca..c723e996ee679 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js @@ -32,11 +32,12 @@ import { hideToolbarOverlay, hideLayerControl, hideViewControl, + setHiddenLayers, } from '../actions/map_actions'; import { setReadOnly, setIsLayerTOCOpen, setOpenTOCDetails } from '../actions/ui_actions'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; import { getInspectorAdapters, setEventHandlers } from '../reducers/non_serializable_instances'; -import { getMapCenter, getMapZoom } from '../selectors/map_selectors'; +import { getMapCenter, getMapZoom, getHiddenLayerIds } from '../selectors/map_selectors'; import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; export class MapEmbeddable extends Embeddable { @@ -153,6 +154,9 @@ export class MapEmbeddable extends Embeddable { } this._store.dispatch(replaceLayerList(this._layerList)); + if (this.input.hiddenLayers) { + this._store.dispatch(setHiddenLayers(this.input.hiddenLayers)); + } this._dispatchSetQuery(this.input); this._dispatchSetRefreshConfig(this.input); @@ -244,5 +248,13 @@ export class MapEmbeddable extends Embeddable { openTOCDetails, }); } + + const hiddenLayerIds = getHiddenLayerIds(this._store.getState()); + + if (!_.isEqual(this.input.hiddenLayers, hiddenLayerIds)) { + this.updateInput({ + hiddenLayers: hiddenLayerIds, + }); + } } } diff --git a/x-pack/legacy/plugins/maps/public/reducers/map.js b/x-pack/legacy/plugins/maps/public/reducers/map.js index 7dd60f013cefd..ac409c685c71a 100644 --- a/x-pack/legacy/plugins/maps/public/reducers/map.js +++ b/x-pack/legacy/plugins/maps/public/reducers/map.js @@ -16,7 +16,7 @@ import { ADD_WAITING_FOR_MAP_READY_LAYER, CLEAR_WAITING_FOR_MAP_READY_LAYER_LIST, REMOVE_LAYER, - TOGGLE_LAYER_VISIBLE, + SET_LAYER_VISIBILITY, MAP_EXTENT_CHANGED, MAP_READY, MAP_DESTROYED, @@ -46,6 +46,7 @@ import { HIDE_TOOLBAR_OVERLAY, HIDE_LAYER_CONTROL, HIDE_VIEW_CONTROL, + SET_WAITING_FOR_READY_HIDDEN_LAYERS, } from '../actions/map_actions'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util'; @@ -307,8 +308,8 @@ export function map(state = INITIAL_STATE, action) { ...state, waitingForMapReadyLayerList: [], }; - case TOGGLE_LAYER_VISIBLE: - return updateLayerInList(state, action.layerId, 'visible'); + case SET_LAYER_VISIBILITY: + return updateLayerInList(state, action.layerId, 'visible', action.visibility); case UPDATE_LAYER_STYLE: const styleLayerId = action.layerId; return updateLayerInList(state, styleLayerId, 'style', { ...action.style }); @@ -376,6 +377,14 @@ export function map(state = INITIAL_STATE, action) { hideViewControl: action.hideViewControl, }, }; + case SET_WAITING_FOR_READY_HIDDEN_LAYERS: + return { + ...state, + waitingForMapReadyLayerList: state.waitingForMapReadyLayerList.map(layer => ({ + ...layer, + visible: !action.hiddenLayerIds.includes(layer.id), + })), + }; default: return state; } diff --git a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js index 3d8e6f97ef077..4b3d1355e4264 100644 --- a/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/legacy/plugins/maps/public/selectors/map_selectors.js @@ -150,6 +150,10 @@ export const getLayerList = createSelector( } ); +export const getHiddenLayerIds = createSelector(getLayerListRaw, layers => + layers.filter(layer => !layer.visible).map(layer => layer.id) +); + export const getSelectedLayer = createSelector( getSelectedLayerId, getLayerList, diff --git a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index aa28831e8d807..a28dc41fa1790 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -37,8 +37,7 @@ function useRefWithCallback() { if (left + tooltipWidth > contentWidth) { // the tooltip is hanging off the side of the page, // so move it to the other side of the target - const markerWidthAdjustment = 25; - left = left - (tooltipWidth + offset.x + markerWidthAdjustment); + left = left - (tooltipWidth + offset.x); } const top = targetPosition.top + offset.y - parentBounding.top; diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap index fb6b9c7c9db66..f7752a1ae25a1 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/__snapshots__/validate_job_view.test.js.snap @@ -86,7 +86,7 @@ exports[`ValidateJob renders the button 1`] = ` iconSide="right" iconType="questionInCircle" isDisabled={false} - isLoading={false} + isLoading={true} onClick={[Function]} size="s" > diff --git a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js index 9b92265c4034b..a5ed7c3753b2f 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/legacy/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -21,6 +21,9 @@ import { EuiOverlayMask, EuiSpacer, EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -38,7 +41,7 @@ const defaultIconType = 'questionInCircle'; const getDefaultState = () => ({ ui: { iconType: defaultIconType, - isLoading: false, + isLoading: true, isModalVisible: false, }, data: { @@ -150,6 +153,14 @@ Callout.propTypes = { }), }; +const LoadingSpinner = () => ( + + + + + +); + const Modal = ({ close, title, children }) => ( @@ -249,10 +260,11 @@ export class ValidateJob extends Component { const isDisabled = this.props.isDisabled !== true ? false : true; const embedded = this.props.embedded === true; const idFilterList = this.props.idFilterList || []; + const isLoading = this.state.ui.isLoading; return ( - {embedded === false && ( + {embedded === false ? (
} > - + {isLoading ? ( + + ) : ( + + )} )}
- )} - {embedded === true && ( - + ) : ( + + {isLoading ? ( + + ) : ( + + )} + )}
); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 22b727452dd8d..6443539a9877d 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -297,10 +297,10 @@ export function getJobCreatorTitle(jobCreator: JobCreatorType) { return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.advanced', { defaultMessage: 'Advanced', }); - // case JOB_TYPE.CATEGORIZATION: - // return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', { - // defaultMessage: 'Categorization', - // }); + case JOB_TYPE.CATEGORIZATION: + return i18n.translate('xpack.ml.newJob.wizard.jobCreatorTitle.categorization', { + defaultMessage: 'Categorization', + }); default: return ''; } diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx index 015300debb156..7f7659d8bb6fd 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field.tsx @@ -10,19 +10,20 @@ import { CategorizationFieldSelect } from './categorization_field_select'; import { JobCreatorContext } from '../../../job_creator_context'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { - MultiMetricJobCreator, - PopulationJobCreator, AdvancedJobCreator, + CategorizationJobCreator, + isCategorizationJobCreator, } from '../../../../../common/job_creator'; import { Description } from './description'; export const CategorizationField: FC = () => { const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); - const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator | AdvancedJobCreator; + const jobCreator = jc as AdvancedJobCreator | CategorizationJobCreator; const { catFields } = newJobCapsService; const [categorizationFieldName, setCategorizationFieldName] = useState( jobCreator.categorizationFieldName ); + const isCategorizationJob = isCategorizationJobCreator(jobCreator); useEffect(() => { if (jobCreator.categorizationFieldName !== categorizationFieldName) { @@ -36,7 +37,7 @@ export const CategorizationField: FC = () => { }, [jobCreatorUpdated]); return ( - + { +interface Props { + isOptional: boolean; +} +export const Description: FC = memo(({ children, isOptional }) => { const title = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.categorizationField.title', { defaultMessage: 'Categorization field', }); @@ -18,10 +21,19 @@ export const Description: FC = memo(({ children }) => { idAria="description" title={

{title}

} description={ - + <> + {isOptional ? ( + + ) : ( + + )} + } > diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index 3a37934e6203a..ece43e00f2eb1 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -133,6 +133,12 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { // auto set the time range if creating a new advanced job autoSetTimeRange = isAdvancedJobCreator(jobCreator); initCategorizationSettings(); + if (isCategorizationJobCreator(jobCreator)) { + const { catFields } = newJobCapsService; + if (catFields.length === 1) { + jobCreator.categorizationFieldName = catFields[0].name; + } + } } if (autoSetTimeRange && isAdvancedJobCreator(jobCreator)) { 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 9db6f8f0a1c35..4d10d73bcc048 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -1523,12 +1523,12 @@ const TimeseriesChartIntl = injectI18n( } else { tooltipData.push({ name: intl.formatMessage({ - id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel', - defaultMessage: 'value', + id: 'xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.actualLabel', + defaultMessage: 'actual', }), - value: formatValue(marker.value, marker.function, fieldFormat), + value: formatValue(marker.actual, marker.function, fieldFormat), seriesKey, - yAccessor: 'value', + yAccessor: 'actual', }); tooltipData.push({ name: intl.formatMessage({ diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 202448340f526..0ab10c4fe69cd 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1394,156 +1394,158 @@ export class TimeSeriesExplorer extends React.Component { jobs.length > 0 && (fullRefresh === false || loading === false) && hasResults === true && ( - - {/* Make sure ChartTooltip is inside this plain wrapping element so positioning can be infered correctly. */} +
+ {/* Make sure ChartTooltip is inside this plain wrapping element without padding so positioning can be infered correctly. */} - - {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { - defaultMessage: 'Single time series analysis of {functionLabel}', - values: { functionLabel: chartDetails.functionLabel }, - })} - -   - {chartDetails.entityData.count === 1 && ( - - {chartDetails.entityData.entities.length > 0 && '('} - {chartDetails.entityData.entities - .map(entity => { - return `${entity.fieldName}: ${entity.fieldValue}`; - }) - .join(', ')} - {chartDetails.entityData.entities.length > 0 && ')'} - - )} - {chartDetails.entityData.count !== 1 && ( - - {chartDetails.entityData.entities.map((countData, i) => { - return ( - - {i18n.translate( - 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', - { - defaultMessage: - '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', - values: { - openBrace: i === 0 ? '(' : '', - closeBrace: - i === chartDetails.entityData.entities.length - 1 ? ')' : '', - cardinalityValue: - countData.cardinality === 0 - ? allValuesLabel - : countData.cardinality, - cardinality: countData.cardinality, - fieldName: countData.fieldName, - }, - } - )} - {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} - - ); + + + {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel: chartDetails.functionLabel }, })} - )} - - {showModelBoundsCheckbox && ( - - + {chartDetails.entityData.entities.length > 0 && '('} + {chartDetails.entityData.entities + .map(entity => { + return `${entity.fieldName}: ${entity.fieldValue}`; + }) + .join(', ')} + {chartDetails.entityData.entities.length > 0 && ')'} + + )} + {chartDetails.entityData.count !== 1 && ( + + {chartDetails.entityData.entities.map((countData, i) => { + return ( + + {i18n.translate( + 'xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', + { + defaultMessage: + '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', + values: { + openBrace: i === 0 ? '(' : '', + closeBrace: + i === chartDetails.entityData.entities.length - 1 ? ')' : '', + cardinalityValue: + countData.cardinality === 0 + ? allValuesLabel + : countData.cardinality, + cardinality: countData.cardinality, + fieldName: countData.fieldName, + }, + } + )} + {i !== chartDetails.entityData.entities.length - 1 ? ', ' : ''} + + ); + })} + + )} + + {showModelBoundsCheckbox && ( + + + + )} + + {showAnnotationsCheckbox && ( + + + + )} + + {showForecastCheckbox && ( + + + + )} + +
+ +
+ {showAnnotations && focusAnnotationData.length > 0 && ( +
+ + {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { + defaultMessage: 'Annotations', })} - checked={showModelBounds} - onChange={this.toggleShowModelBoundsHandler} + + - + +
)} - - {showAnnotationsCheckbox && ( - - + + {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { + defaultMessage: 'Anomalies', + })} + + + + + > + + - )} - - {showForecastCheckbox && ( - - + + > + + - )} - -
- -
- {showAnnotations && focusAnnotationData.length > 0 && ( -
- - {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { - defaultMessage: 'Annotations', - })} - - - -
- )} - - - {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { - defaultMessage: 'Anomalies', - })} - - - - - - - - - - - - - - - + + + +
)} {arePartitioningFieldsProvided && jobs.length > 0 && ( diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts index a7afee237dba9..8cdaa192fcbc9 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -345,6 +345,98 @@ describe('ML - custom URL utils', () => { ); }); + test('returns expected URL for APM', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:"$trace.id$" and transaction.name:"$transaction.name$"&_g=()', + }; + + const testRecords = { + job_id: 'abnormal_trace_durations_nodejs', + result_type: 'record', + probability: 0.025597710862701226, + multi_bucket_impact: 5, + record_score: 13.124152090331723, + initial_record_score: 13.124152090331723, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573339500000, + by_field_name: 'transaction.name', + by_field_value: 'GET /test-data', + function: 'high_mean', + function_description: 'mean', + typical: [802.0600710562369], + actual: [761.1531339031332], + field_name: 'transaction.duration.us', + influencers: [ + { + influencer_field_name: 'transaction.name', + influencer_field_values: ['GET /test-data'], + }, + { + influencer_field_name: 'trace.id', + influencer_field_values: [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + }, + { + influencer_field_name: 'service.name', + influencer_field_values: ['example-service'], + }, + ], + 'trace.id': [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + 'service.name': ['example-service'], + 'transaction.name': ['GET /test-data'], + earliest: '2019-11-09T20:45:00.000Z', + latest: '2019-11-10T01:00:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/traces?rangeFrom=2019-11-09T20:45:00.000Z&rangeTo=2019-11-10T01:00:00.000Z&kuery=(trace.id:"000a09d58a428f38550e7e87637733c1" OR trace.id:"0039c771d8bbadf6137767d3aeb89f96" OR trace.id:"01279ed5bb9f4249e3822d16dec7f2f2") AND transaction.name:"GET%20%2Ftest-data"&_g=()' + ); + }); + + test('removes an empty path component with a trailing slash', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request', + }; + + const testRecords = { + job_id: 'decreased_throughput_jsbase', + result_type: 'record', + probability: 8.91350850732573e-9, + multi_bucket_impact: 5, + record_score: 93.63625728951217, + initial_record_score: 93.63625728951217, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573266600000, + function: 'low_count', + function_description: 'count', + typical: [100615.66506877479], + actual: [25251], + earliest: '2019-11-09T00:30:00.000Z', + latest: '2019-11-09T04:45:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/services/transactions?rangeFrom=2019-11-09T00:30:00.000Z&rangeTo=2019-11-09T04:45:00.000Z&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request' + ); + }); + test('returns expected URL for other type URL', () => { expect(getUrlForRecord(TEST_OTHER_URL, TEST_RECORD)).toBe( 'http://airlinecodes.info/airline-code-AAL' diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts index e2f2dc0ad0fe8..7774f6dec0c95 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts @@ -97,7 +97,11 @@ export function openCustomUrlWindow(fullUrl: string, urlConfig: UrlConfig) { // a Kibana Discover or Dashboard page running on the same server as this ML plugin. function isKibanaUrl(urlConfig: UrlConfig) { const urlValue = urlConfig.url_value; - return urlValue.startsWith('kibana#/discover') || urlValue.startsWith('kibana#/dashboard'); + return ( + urlValue.startsWith('kibana#/discover') || + urlValue.startsWith('kibana#/dashboard') || + urlValue.startsWith('apm#/') + ); } /** @@ -136,13 +140,14 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); - return str.replace(/\$([^?&$\'"]+)\$/g, (match, name: string) => { + // Looking for a $token$ with an optional trailing slash + return str.replace(/\$([^?&$\'"]+)\$(\/)?/g, (match, name: string, slash: string = '') => { // Use lodash get to allow nested JSON fields to be retrieved. let tokenValue: string | string[] | undefined = get(record, name); tokenValue = Array.isArray(tokenValue) ? tokenValue[0] : tokenValue; - // If property not found string is not replaced. - return tokenValue === undefined ? match : getResultTokenValue(tokenValue); + // If property not found token is replaced with an empty string. + return tokenValue === undefined ? '' : getResultTokenValue(tokenValue) + slash; }); }; @@ -155,7 +160,7 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); return str.replace( - /(.+query:')([^']*)('.+)/, + /(.+query:'|.+&kuery=)([^']*)(['&].+)/, (fullMatch, prefix: string, queryString: string, postfix: string) => { const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues); @@ -170,28 +175,39 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) const queryParts: string[] = []; const joinOperator = ' AND '; - for (let i = 0; i < queryFields.length; i++) { + fieldsLoop: for (let i = 0; i < queryFields.length; i++) { const field = queryFields[i]; // Use lodash get to allow nested JSON fields to be retrieved. - const tokenValues: string[] | string | null = get(record, field) || null; + let tokenValues: string[] | string | null = get(record, field) || null; if (tokenValues === null) { continue; } + tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues]; + // Create a pair `influencerField:value`. // In cases where there are multiple influencer field values for an anomaly // combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`. - let result = (Array.isArray(tokenValues) ? tokenValues : [tokenValues]) - .map(value => `${field}:"${getResultTokenValue(value)}"`) - .join(' OR '); - result = tokenValues.length > 1 ? `(${result})` : result; - - // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. - availableCharactersLeft -= result.length - (i === 0 ? 0 : joinOperator.length); - - if (availableCharactersLeft <= 0) { - break; - } else { - queryParts.push(result); + let result = ''; + for (let j = 0; j < tokenValues.length; j++) { + const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue( + tokenValues[j] + )}"`; + + // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. + if (availableCharactersLeft < part.length) { + if (result.length > 0) { + queryParts.push(j > 0 ? `(${result})` : result); + } + break fieldsLoop; + } + + result += part; + + availableCharactersLeft -= result.length; + } + + if (result.length > 0) { + queryParts.push(tokenValues.length > 1 ? `(${result})` : result); } } diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js index cb268ffede7fa..9c5048daeee3f 100644 --- a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js @@ -12,6 +12,8 @@ describe('ML - data recognizer', () => { const moduleIds = [ 'apache_ecs', + 'apm_jsbase', + 'apm_nodejs', 'apm_transaction', 'auditbeat_process_docker_ecs', 'auditbeat_process_hosts_ecs', diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json new file mode 100644 index 0000000000000..3905c809fbd7a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json new file mode 100644 index 0000000000000..e463b34be0fc2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json @@ -0,0 +1,53 @@ +{ + "id": "apm_jsbase", + "title": "APM: RUM Javascript", + "description": "Detect problematic spans and identify user agents that are potentially causing issues.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { + "filter": [{ "term": { "agent.name": "js-base" } }] + } + }, + "jobs": [ + { + "id": "abnormal_span_durations_jsbase", + "file": "abnormal_span_durations_jsbase.json" + }, + { + "id": "anomalous_error_rate_for_user_agents_jsbase", + "file": "anomalous_error_rate_for_user_agents_jsbase.json" + }, + { + "id": "decreased_throughput_jsbase", + "file": "decreased_throughput_jsbase.json" + }, + { + "id": "high_count_by_user_agent_jsbase", + "file": "high_count_by_user_agent_jsbase.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_jsbase", + "file": "datafeed_abnormal_span_durations_jsbase.json", + "job_id": "abnormal_span_durations_jsbase" + }, + { + "id": "datafeed-anomalous_error_rate_for_user_agents_jsbase", + "file": "datafeed_anomalous_error_rate_for_user_agents_jsbase.json", + "job_id": "anomalous_error_rate_for_user_agents_jsbase" + }, + { + "id": "datafeed-decreased_throughput_jsbase", + "file": "datafeed_decreased_throughput_jsbase.json", + "job_id": "decreased_throughput_jsbase" + }, + { + "id": "datafeed-high_count_by_user_agent_jsbase", + "file": "datafeed_high_count_by_user_agent_jsbase.json", + "job_id": "high_count_by_user_agent_jsbase" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json new file mode 100644 index 0000000000000..e0b51a4dcd05e --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 0000000000000..66fd9858c6885 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are encountering errors at an above normal rate. This can help detect browser compatibility issues.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high error rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "error.exception.message.keyword", + "error.page.url", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/errors?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json new file mode 100644 index 0000000000000..7ecbe2890b826 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 0000000000000..fbfedcbf47561 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "exists": { "field": "user_agent.name" } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json new file mode 100644 index 0000000000000..48cba1f157815 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "js-base" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json new file mode 100644 index 0000000000000..18ca6b1389287 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": [{ "exists": { "field": "user_agent.name" } }] } }, + { "bool": { "filter": { "term": { "processor.event": "transaction" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json new file mode 100644 index 0000000000000..4bc8757f19dc9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json new file mode 100644 index 0000000000000..7e1316359eabb --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are making requests at a suspiciously high rate. This is useful in identifying bots.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high request rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json new file mode 100644 index 0000000000000..3905c809fbd7a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json new file mode 100644 index 0000000000000..1865a33a1d301 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "apm_nodejs", + "title": "APM: NodeJS", + "description": "Detect abnormal traces, anomalous spans, and identify periods of decreased throughput.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { "filter": [{ "term": { "agent.name": "nodejs" } }] } + }, + "jobs": [ + { + "id": "abnormal_span_durations_nodejs", + "file": "abnormal_span_durations_nodejs.json" + }, + { + "id": "abnormal_trace_durations_nodejs", + "file": "abnormal_trace_durations_nodejs.json" + }, + { + "id": "decreased_throughput_nodejs", + "file": "decreased_throughput_nodejs.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_nodejs", + "file": "datafeed_abnormal_span_durations_nodejs.json", + "job_id": "abnormal_span_durations_nodejs" + }, + { + "id": "datafeed-abnormal_trace_durations_nodejs", + "file": "datafeed_abnormal_trace_durations_nodejs.json", + "job_id": "abnormal_trace_durations_nodejs" + }, + { + "id": "datafeed-decreased_throughput_nodejs", + "file": "datafeed_decreased_throughput_nodejs.json", + "job_id": "decreased_throughput_nodejs" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json new file mode 100644 index 0000000000000..1a8318437790e --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json new file mode 100644 index 0000000000000..875b49e895a00 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies trace transactions that are processing more slowly than usual.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased trace duration", + "function": "high_mean", + "field_name": "transaction.duration.us", + "by_field_name": "transaction.name" + } + ], + "influencers": [ + "transaction.name", + "trace.id", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\" and transaction.name:\"$transaction.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json new file mode 100644 index 0000000000000..3e4f4877bd042 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json new file mode 100644 index 0000000000000..d87f809a49940 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json @@ -0,0 +1,13 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must_not": [{ "exists": { "field": "parent.id" } }], + "must": [{ "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json new file mode 100644 index 0000000000000..451957c327dd0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "nodejs" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json new file mode 100644 index 0000000000000..f63c6289a5cd9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "transaction.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js index 57fdbd5cc6238..c08ae91769b9d 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/__test__/get_paginated_nodes.test.js @@ -58,7 +58,7 @@ describe('getPaginatedNodes', () => { }, }, }; - const shardStats = { + const nodesShardCount = { nodes: { 1: { shardCount: 10, @@ -78,7 +78,7 @@ describe('getPaginatedNodes', () => { pagination, sort, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [ @@ -98,7 +98,7 @@ describe('getPaginatedNodes', () => { pagination, { ...sort, field: 'foo', direction: 'desc' }, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [ @@ -118,7 +118,7 @@ describe('getPaginatedNodes', () => { pagination, sort, 'tw', - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ); expect(nodes).toEqual({ pageOfNodes: [{ name: 'two', uuid: 2, isOnline: false, shardCount: 5, foo: 12 }], diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js index 4bfd0090fced0..7581a32590971 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_nodes.js @@ -29,7 +29,7 @@ import { LISTING_METRICS_NAMES, LISTING_METRICS_PATHS } from './nodes_listing_me * @param {Object} shardStats: per-node information about shards * @return {Array} node info combined with metrics for each node from handle_response */ -export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats) { +export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, nodesShardCount) { checkParam(esIndexPattern, 'esIndexPattern in getNodes'); const start = moment.utc(req.payload.timeRange.min).valueOf(); @@ -104,5 +104,9 @@ export async function getNodes(req, esIndexPattern, pageOfNodes, clusterStats, s const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); const response = await callWithRequest(req, 'search', params); - return handleResponse(response, clusterStats, shardStats, pageOfNodes, { min, max, bucketSize }); + return handleResponse(response, clusterStats, nodesShardCount, pageOfNodes, { + min, + max, + bucketSize, + }); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js index 15084d952b343..0023b9515ad1c 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/get_paginated_nodes.js @@ -35,7 +35,7 @@ export async function getPaginatedNodes( pagination, sort, queryText, - { clusterStats, shardStats } + { clusterStats, nodesShardCount } ) { const config = req.server.config(); const size = config.get('xpack.monitoring.max_bucket_size'); @@ -45,7 +45,7 @@ export async function getPaginatedNodes( const clusterState = get(clusterStats, 'cluster_state', { nodes: {} }); for (const node of nodes) { node.isOnline = !isUndefined(get(clusterState, ['nodes', node.uuid])); - node.shardCount = get(shardStats, `nodes[${node.uuid}].shardCount`, 0); + node.shardCount = get(nodesShardCount, `nodes[${node.uuid}].shardCount`, 0); } // `metricSet` defines a list of metrics that are sortable in the UI diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js index 55072a1086641..651fd20d77554 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/handle_response.js @@ -13,17 +13,23 @@ import { uncovertMetricNames } from '../../convert_metric_names'; * Process the response from the get_nodes query * @param {Object} response: response data from get_nodes * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @param {Object} timeOptions: min, max, and bucketSize needed for date histogram creation * @return {Array} node info combined with metrics for each node */ -export function handleResponse(response, clusterStats, shardStats, pageOfNodes, timeOptions = {}) { +export function handleResponse( + response, + clusterStats, + nodesShardCount, + pageOfNodes, + timeOptions = {} +) { if (!get(response, 'hits.hits')) { return []; } const nodeHits = get(response, 'hits.hits', []); - const nodesInfo = mapNodesInfo(nodeHits, clusterStats, shardStats); + const nodesInfo = mapNodesInfo(nodeHits, clusterStats, nodesShardCount); /* * Every node bucket is an object with a field for nodeId and fields for diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js index 23ee614d48ec4..3c719c2ddfbf8 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/nodes/get_nodes/map_nodes_info.js @@ -10,10 +10,10 @@ import { calculateNodeType, getNodeTypeClassLabel } from '../'; /** * @param {Array} nodeHits: info about each node from the hits in the get_nodes query * @param {Object} clusterStats: cluster stats from cluster state document - * @param {Object} shardStats: per-node information about shards + * @param {Object} nodesShardCount: per-node information about shards * @return {Object} summarized info about each node keyed by nodeId */ -export function mapNodesInfo(nodeHits, clusterStats, shardStats) { +export function mapNodesInfo(nodeHits, clusterStats, nodesShardCount) { const clusterState = get(clusterStats, 'cluster_state', { nodes: {} }); return nodeHits.reduce((prev, node) => { @@ -35,7 +35,7 @@ export function mapNodesInfo(nodeHits, clusterStats, shardStats) { isOnline, nodeTypeLabel: nodeTypeLabel, nodeTypeClass: nodeTypeClass, - shardCount: get(shardStats, `nodes[${sourceNode.uuid}].shardCount`, 0), + shardCount: get(nodesShardCount, `nodes[${sourceNode.uuid}].shardCount`, 0), }, }; }, {}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js new file mode 100644 index 0000000000000..e8d484e7021f4 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.js @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { checkParam } from '../../error_missing_required'; +import { createQuery } from '../../create_query'; +import { ElasticsearchMetric } from '../../metrics'; +import { calculateIndicesTotals } from './calculate_shard_stat_indices_totals'; + +async function getUnassignedShardData(req, esIndexPattern, cluster) { + const config = req.server.config(); + const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); + const metric = ElasticsearchMetric.getMetricFields(); + + const params = { + index: esIndexPattern, + size: 0, + ignoreUnavailable: true, + body: { + sort: { timestamp: { order: 'desc' } }, + query: createQuery({ + type: 'shards', + clusterUuid: cluster.cluster_uuid, + metric, + filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + }), + aggs: { + indices: { + terms: { + field: 'shard.index', + size: maxBucketSize, + }, + aggs: { + state: { + filter: { + terms: { + 'shard.state': ['UNASSIGNED', 'INITIALIZING'], + }, + }, + aggs: { + primary: { + terms: { + field: 'shard.primary', + size: 2, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + return await callWithRequest(req, 'search', params); +} + +export async function getIndicesUnassignedShardStats(req, esIndexPattern, cluster) { + checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); + + const response = await getUnassignedShardData(req, esIndexPattern, cluster); + const indices = get(response, 'aggregations.indices.buckets', []).reduce((accum, bucket) => { + const index = bucket.key; + const states = get(bucket, 'state.primary.buckets', []); + const unassignedReplica = states + .filter(state => state.key_as_string === 'false') + .reduce((total, state) => total + state.doc_count, 0); + const unassignedPrimary = states + .filter(state => state.key_as_string === 'true') + .reduce((total, state) => total + state.doc_count, 0); + + let status = 'green'; + if (unassignedReplica > 0) { + status = 'yellow'; + } + if (unassignedPrimary > 0) { + status = 'red'; + } + + accum[index] = { + unassigned: { primary: unassignedPrimary, replica: unassignedReplica }, + status, + }; + return accum; + }, {}); + + const indicesTotals = calculateIndicesTotals(indices); + return { indices, indicesTotals }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js new file mode 100644 index 0000000000000..a899b48cdd434 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_indices_unassigned_shard_stats.test.js @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getIndicesUnassignedShardStats } from './get_indices_unassigned_shard_stats'; + +describe('getIndicesUnassignedShardStats', () => { + it('should return the unassigned shard stats for indices', async () => { + const indices = { + 12345: { status: 'red', unassigned: { primary: 1, replica: 0 } }, + 6789: { status: 'yellow', unassigned: { primary: 0, replica: 1 } }, + absdf82: { status: 'green', unassigned: { primary: 0, replica: 0 } }, + }; + + const req = { + server: { + config: () => ({ + get: () => {}, + }), + plugins: { + elasticsearch: { + getCluster: () => ({ + callWithRequest: () => ({ + aggregations: { + indices: { + buckets: Object.keys(indices).map(id => ({ + key: id, + state: { + primary: { + buckets: + indices[id].unassigned.primary || indices[id].unassigned.replica + ? [ + { + key_as_string: indices[id].unassigned.primary + ? 'true' + : 'false', + doc_count: 1, + }, + ] + : [], + }, + }, + })), + }, + }, + }), + }), + }, + }, + }, + }; + const esIndexPattern = '*'; + const cluster = {}; + const stats = await getIndicesUnassignedShardStats(req, esIndexPattern, cluster); + expect(stats.indices).toEqual(indices); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js new file mode 100644 index 0000000000000..c11bd4aead693 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.js @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { checkParam } from '../../error_missing_required'; +import { createQuery } from '../../create_query'; +import { ElasticsearchMetric } from '../../metrics'; + +async function getShardCountPerNode(req, esIndexPattern, cluster) { + const config = req.server.config(); + const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); + const metric = ElasticsearchMetric.getMetricFields(); + + const params = { + index: esIndexPattern, + size: 0, + ignoreUnavailable: true, + body: { + sort: { timestamp: { order: 'desc' } }, + query: createQuery({ + type: 'shards', + clusterUuid: cluster.cluster_uuid, + metric, + filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + }), + aggs: { + nodes: { + terms: { + field: 'shard.node', + size: maxBucketSize, + }, + }, + }, + }, + }; + + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + return await callWithRequest(req, 'search', params); +} + +export async function getNodesShardCount(req, esIndexPattern, cluster) { + checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); + + const response = await getShardCountPerNode(req, esIndexPattern, cluster); + const nodes = get(response, 'aggregations.nodes.buckets', []).reduce((accum, bucket) => { + accum[bucket.key] = { shardCount: bucket.doc_count }; + return accum; + }, {}); + return { nodes }; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js new file mode 100644 index 0000000000000..023f12db1bf46 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_nodes_shard_count.test.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getNodesShardCount } from './get_nodes_shard_count'; + +describe('getNodeShardCount', () => { + it('should return the shard count per node', async () => { + const nodes = { + 12345: { shardCount: 10 }, + 6789: { shardCount: 1 }, + absdf82: { shardCount: 20 }, + }; + + const req = { + server: { + config: () => ({ + get: () => {}, + }), + plugins: { + elasticsearch: { + getCluster: () => ({ + callWithRequest: () => ({ + aggregations: { + nodes: { + buckets: Object.keys(nodes).map(id => ({ + key: id, + doc_count: nodes[id].shardCount, + })), + }, + }, + }), + }), + }, + }, + }, + }; + const esIndexPattern = '*'; + const cluster = {}; + const counts = await getNodesShardCount(req, esIndexPattern, cluster); + expect(counts.nodes).toEqual(nodes); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js index a718ef8569dbf..eddd50612cdb1 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stat_aggs.js @@ -8,7 +8,7 @@ * @param {Object} config - Kibana config service * @param {Boolean} includeNodes - whether to add the aggs for node shards */ -export function getShardAggs(config, includeNodes) { +export function getShardAggs(config, includeNodes, includeIndices) { const maxBucketSize = config.get('xpack.monitoring.max_bucket_size'); const aggSize = 10; const indicesAgg = { @@ -40,7 +40,7 @@ export function getShardAggs(config, includeNodes) { }; return { - ...{ indices: indicesAgg }, + ...{ indices: includeIndices ? indicesAgg : undefined }, ...{ nodes: includeNodes ? nodesAgg : undefined }, }; } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js index c77e03673bb4c..132e9d6b01dbe 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/elasticsearch/shards/get_shard_stats.js @@ -21,11 +21,11 @@ export function handleResponse(resp, includeNodes, includeIndices, cluster) { if (buckets && buckets.length !== 0) { indices = buckets.reduce(normalizeIndexShards, {}); indicesTotals = calculateIndicesTotals(indices); + } - if (includeNodes) { - const masterNode = get(cluster, 'cluster_state.master_node'); - nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); - } + if (includeNodes) { + const masterNode = get(cluster, 'cluster_state.master_node'); + nodes = resp.aggregations.nodes.buckets.reduce(normalizeNodeShards(masterNode), {}); } return { @@ -39,12 +39,19 @@ export function getShardStats( req, esIndexPattern, cluster, - { includeNodes = false, includeIndices = false } = {} + { includeNodes = false, includeIndices = false, indexName = null, nodeUuid = null } = {} ) { checkParam(esIndexPattern, 'esIndexPattern in elasticsearch/getShardStats'); const config = req.server.config(); const metric = ElasticsearchMetric.getMetricFields(); + const filters = [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }]; + if (indexName) { + filters.push({ term: { 'shard.index': indexName } }); + } + if (nodeUuid) { + filters.push({ term: { 'shard.node': nodeUuid } }); + } const params = { index: esIndexPattern, size: 0, @@ -55,10 +62,10 @@ export function getShardStats( type: 'shards', clusterUuid: cluster.cluster_uuid, metric, - filters: [{ term: { state_uuid: get(cluster, 'cluster_state.state_uuid') } }], + filters, }), aggs: { - ...getShardAggs(config, includeNodes), + ...getShardAggs(config, includeNodes, includeIndices), }, }, }; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js index e6380a724590e..c32e25d9f20d1 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/index_detail.js @@ -60,6 +60,7 @@ export function esIndexRoute(server) { const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeNodes: true, includeIndices: true, + indexName: indexUuid, }); const indexSummary = await getIndexSummary(req, esIndexPattern, shardStats, { clusterUuid, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js index c8cf4bd29e26d..241b54fbf0c2a 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/indices.js @@ -8,10 +8,10 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getIndices } from '../../../../lib/elasticsearch/indices'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esIndicesRoute(server) { server.route({ @@ -43,13 +43,20 @@ export function esIndicesRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats, { - includeIndices: true, - }); - const indices = await getIndices(req, esIndexPattern, showSystemIndices, shardStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); + const indices = await getIndices( + req, + esIndexPattern, + showSystemIndices, + indicesUnassignedShardStats + ); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), indices, }; } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js index 1876f751dd166..de3b9863d9141 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/ml_jobs.js @@ -8,10 +8,10 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getMlJobs } from '../../../../lib/elasticsearch/get_ml_jobs'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function mlJobRoute(server) { server.route({ @@ -39,11 +39,15 @@ export function mlJobRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); const rows = await getMlJobs(req, esIndexPattern); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), rows, }; } catch (err) { diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js index 5da2e7128e7e4..10226d74ed001 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js @@ -78,6 +78,7 @@ export function esNodeRoute(server) { const shardStats = await getShardStats(req, esIndexPattern, cluster, { includeIndices: true, includeNodes: true, + nodeUuid, }); const nodeSummary = await getNodeSummary(req, esIndexPattern, clusterState, shardStats, { clusterUuid, diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js index 88e65332603ad..fb2d04ecc041d 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/nodes.js @@ -8,12 +8,13 @@ import Joi from 'joi'; import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getNodes } from '../../../../lib/elasticsearch/nodes'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; +import { getNodesShardCount } from '../../../../lib/elasticsearch/shards/get_nodes_shard_count'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH } from '../../../../../common/constants'; import { getPaginatedNodes } from '../../../../lib/elasticsearch/nodes/get_nodes/get_paginated_nodes'; import { LISTING_METRICS_NAMES } from '../../../../lib/elasticsearch/nodes/get_nodes/nodes_listing_metrics'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esNodesRoute(server) { server.route({ @@ -53,10 +54,13 @@ export function esNodesRoute(server) { try { const clusterStats = await getClusterStats(req, esIndexPattern, clusterUuid); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats, { - includeNodes: true, - }); - const clusterStatus = getClusterStatus(clusterStats, shardStats); + const nodesShardCount = await getNodesShardCount(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); + const clusterStatus = getClusterStatus(clusterStats, indicesUnassignedShardStats); const metricSet = LISTING_METRICS_NAMES; const { pageOfNodes, totalNodeCount } = await getPaginatedNodes( @@ -69,11 +73,17 @@ export function esNodesRoute(server) { queryText, { clusterStats, - shardStats, + nodesShardCount, } ); - const nodes = await getNodes(req, esIndexPattern, pageOfNodes, clusterStats, shardStats); + const nodes = await getNodes( + req, + esIndexPattern, + pageOfNodes, + clusterStats, + nodesShardCount + ); return { clusterStatus, nodes, totalNodeCount }; } catch (err) { throw handleError(err, req); diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js index 9022471dfb7f8..b0045502fa228 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/elasticsearch/overview.js @@ -9,7 +9,6 @@ import { getClusterStats } from '../../../../lib/cluster/get_cluster_stats'; import { getClusterStatus } from '../../../../lib/cluster/get_cluster_status'; import { getLastRecovery } from '../../../../lib/elasticsearch/get_last_recovery'; import { getMetrics } from '../../../../lib/details/get_metrics'; -import { getShardStats } from '../../../../lib/elasticsearch/shards'; import { handleError } from '../../../../lib/errors/handle_error'; import { prefixIndexPattern } from '../../../../lib/ccs_utils'; import { metricSet } from './metric_set_overview'; @@ -18,6 +17,7 @@ import { INDEX_PATTERN_FILEBEAT, } from '../../../../../common/constants'; import { getLogs } from '../../../../lib/logs'; +import { getIndicesUnassignedShardStats } from '../../../../lib/elasticsearch/shards/get_indices_unassigned_shard_stats'; export function esOverviewRoute(server) { server.route({ @@ -54,10 +54,14 @@ export function esOverviewRoute(server) { getLastRecovery(req, esIndexPattern), getLogs(config, req, filebeatIndexPattern, { clusterUuid, start, end }), ]); - const shardStats = await getShardStats(req, esIndexPattern, clusterStats); + const indicesUnassignedShardStats = await getIndicesUnassignedShardStats( + req, + esIndexPattern, + clusterStats + ); return { - clusterStatus: getClusterStatus(clusterStats, shardStats), + clusterStatus: getClusterStatus(clusterStats, indicesUnassignedShardStats), metrics, logs, shardActivity, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json index 1f17a7b78a29e..584618057256a 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/fixtures/beats_stats_results.json @@ -2,6 +2,36 @@ { "hits": { "hits": [ + { + "_source" : { + "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", + "type": "beats_state", + "beats_state" : { + "state" : { + "functionbeat" : { + "functions": { + "count": 1 + } + } + } + } + } + }, + { + "_source" : { + "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", + "type": "beats_state", + "beats_state" : { + "state" : { + "functionbeat" : { + "functions": { + "count": 3 + } + } + } + } + } + }, { "_source" : { "cluster_uuid": "W7hppdX7R229Oy3KQbZrTw", diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js index 7734441a302c3..522be71555fba 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/__tests__/get_beats_stats.js @@ -168,6 +168,11 @@ describe('Get Beats Stats', () => { }, monitors: 3, }, + functionbeat: { + functions: { + count: 4, + }, + }, }, FlV4ckTxQ0a78hmBkzzc9A: { count: 405, diff --git a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js index 94f710d51cc35..5722228b60207 100644 --- a/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js +++ b/x-pack/legacy/plugins/monitoring/server/telemetry_collection/get_beats_stats.js @@ -138,6 +138,23 @@ export function processResults( } } + const functionbeatState = get(hit, '_source.beats_state.state.functionbeat'); + if (functionbeatState !== undefined) { + if (!clusters[clusterUuid].hasOwnProperty('functionbeat')) { + clusters[clusterUuid].functionbeat = { + functions: { + count: 0, + }, + }; + } + + clusters[clusterUuid].functionbeat.functions.count += get( + functionbeatState, + 'functions.count', + 0 + ); + } + const stateHost = get(hit, '_source.beats_state.state.host'); if (stateHost !== undefined) { const hostMap = clusterArchitectureMaps[clusterUuid]; diff --git a/x-pack/legacy/plugins/security/common/model.ts b/x-pack/legacy/plugins/security/common/model.ts index 90e6a5403dfe8..733e89f774db8 100644 --- a/x-pack/legacy/plugins/security/common/model.ts +++ b/x-pack/legacy/plugins/security/common/model.ts @@ -11,12 +11,17 @@ export { BuiltinESPrivileges, EditUser, FeaturesPrivileges, + InlineRoleTemplate, + InvalidRoleTemplate, KibanaPrivileges, RawKibanaFeaturePrivileges, RawKibanaPrivileges, Role, RoleIndexPrivilege, RoleKibanaPrivilege, + RoleMapping, + RoleTemplate, + StoredRoleTemplate, User, canUserChangePassword, getUserDisplayName, diff --git a/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts new file mode 100644 index 0000000000000..b8bcba91388b5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { RoleMapping } from '../../common/model'; + +interface CheckRoleMappingFeaturesResponse { + canManageRoleMappings: boolean; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + hasCompatibleRealms: boolean; +} + +type DeleteRoleMappingsResponse = Array<{ + name: string; + success: boolean; + error?: Error; +}>; + +export class RoleMappingsAPI { + constructor(private readonly http: CoreSetup['http']) {} + + public async checkRoleMappingFeatures(): Promise { + return this.http.get(`/internal/security/_check_role_mapping_features`); + } + + public async getRoleMappings(): Promise { + return this.http.get(`/internal/security/role_mapping`); + } + + public async getRoleMapping(name: string): Promise { + return this.http.get(`/internal/security/role_mapping/${encodeURIComponent(name)}`); + } + + public async saveRoleMapping(roleMapping: RoleMapping) { + const payload = { ...roleMapping }; + delete payload.name; + + return this.http.post( + `/internal/security/role_mapping/${encodeURIComponent(roleMapping.name)}`, + { body: JSON.stringify(payload) } + ); + } + + public async deleteRoleMappings(names: string[]): Promise { + return Promise.all( + names.map(name => + this.http + .delete(`/internal/security/role_mapping/${encodeURIComponent(name)}`) + .then(() => ({ success: true, name })) + .catch(error => ({ success: false, name, error })) + ) + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/_index.scss b/x-pack/legacy/plugins/security/public/views/management/_index.scss index 104fed5980543..78b53845071e4 100644 --- a/x-pack/legacy/plugins/security/public/views/management/_index.scss +++ b/x-pack/legacy/plugins/security/public/views/management/_index.scss @@ -1,3 +1,4 @@ @import './change_password_form/index'; @import './edit_role/index'; -@import './edit_user/index'; \ No newline at end of file +@import './edit_user/index'; +@import './role_mappings/edit_role_mapping/index'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts index 7d345ac13dc41..4ab7e45e84849 100644 --- a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts +++ b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts @@ -86,3 +86,30 @@ export function getApiKeysBreadcrumbs() { }, ]; } + +export function getRoleMappingBreadcrumbs() { + return [ + MANAGEMENT_BREADCRUMB, + { + text: i18n.translate('xpack.security.roleMapping.breadcrumb', { + defaultMessage: 'Role Mappings', + }), + href: '#/management/security/role_mappings', + }, + ]; +} + +export function getEditRoleMappingBreadcrumbs($route: Record) { + const { name } = $route.current.params; + return [ + ...getRoleMappingBreadcrumbs(), + { + text: + name || + i18n.translate('xpack.security.roleMappings.createBreadcrumb', { + defaultMessage: 'Create', + }), + href: `#/management/security/role_mappings/edit/${name}`, + }, + ]; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js index 59da63abbb83f..f0369f232aeba 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management.js +++ b/x-pack/legacy/plugins/security/public/views/management/management.js @@ -11,9 +11,11 @@ import 'plugins/security/views/management/roles_grid/roles'; import 'plugins/security/views/management/api_keys_grid/api_keys'; import 'plugins/security/views/management/edit_user/edit_user'; import 'plugins/security/views/management/edit_role/index'; +import 'plugins/security/views/management/role_mappings/role_mappings_grid'; +import 'plugins/security/views/management/role_mappings/edit_role_mapping'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls'; +import { ROLES_PATH, USERS_PATH, API_KEYS_PATH, ROLE_MAPPINGS_PATH } from './management_urls'; import { management } from 'ui/management'; import { npSetup } from 'ui/new_platform'; @@ -38,11 +40,23 @@ routes resolve: { securityManagementSection: function() { const showSecurityLinks = xpackInfo.get('features.security.showLinks'); + const showRoleMappingsManagementLink = xpackInfo.get( + 'features.security.showRoleMappingsManagement' + ); function deregisterSecurity() { management.deregister('security'); } + function deregisterRoleMappingsManagement() { + if (management.hasItem('security')) { + const security = management.getSection('security'); + if (security.hasItem('roleMappings')) { + security.deregister('roleMappings'); + } + } + } + function ensureSecurityRegistered() { const registerSecurity = () => management.register('security', { @@ -88,11 +102,26 @@ routes url: `#${API_KEYS_PATH}`, }); } + + if (showRoleMappingsManagementLink && !security.hasItem('roleMappings')) { + security.register('roleMappings', { + name: 'securityRoleMappingLink', + order: 30, + display: i18n.translate('xpack.security.management.roleMappingsTitle', { + defaultMessage: 'Role Mappings', + }), + url: `#${ROLE_MAPPINGS_PATH}`, + }); + } } if (!showSecurityLinks) { deregisterSecurity(); } else { + if (!showRoleMappingsManagementLink) { + deregisterRoleMappingsManagement(); + } + // getCurrentUser will reject if there is no authenticated user, so we prevent them from // seeing the security management screens. return npSetup.plugins.security.authc diff --git a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts index ea0cba9f7f3a7..881740c0b2895 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts +++ b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts @@ -12,3 +12,13 @@ export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`; export const USERS_PATH = `${SECURITY_PATH}/users`; export const EDIT_USERS_PATH = `${USERS_PATH}/edit`; export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`; +export const ROLE_MAPPINGS_PATH = `${SECURITY_PATH}/role_mappings`; +export const CREATE_ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/edit`; + +export const getEditRoleHref = (roleName: string) => + `#${EDIT_ROLES_PATH}/${encodeURIComponent(roleName)}`; + +export const getCreateRoleMappingHref = () => `#${CREATE_ROLE_MAPPING_PATH}`; + +export const getEditRoleMappingHref = (roleMappingName: string) => + `#${CREATE_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx new file mode 100644 index 0000000000000..b826d68053e27 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { DeleteProvider } from '.'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { RoleMapping } from '../../../../../../common/model'; +import { EuiConfirmModal } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { act } from '@testing-library/react'; +import { toastNotifications } from 'ui/notify'; + +jest.mock('ui/notify', () => { + return { + toastNotifications: { + addError: jest.fn(), + addSuccess: jest.fn(), + addDanger: jest.fn(), + }, + }; +}); + +describe('DeleteProvider', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('allows a single role mapping to be deleted', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props(); + expect(title).toMatchInlineSnapshot(`"Delete role mapping 'delete-me'?"`); + expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mapping"`); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted role mapping 'delete-me'", + }, + ] + `); + }); + + it('allows multiple role mappings to be deleted', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + { + name: 'delete-me-too', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + { + name: 'delete-me-too', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props(); + expect(title).toMatchInlineSnapshot(`"Delete 2 role mappings?"`); + expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mappings"`); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([ + 'delete-me', + 'delete-me-too', + ]); + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted 2 role mappings", + }, + ] + `); + }); + + it('handles mixed success/failure conditions', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + { + name: 'i-wont-work', + success: false, + error: new Error('something went wrong. sad.'), + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + { + name: 'i-wont-work', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([ + 'delete-me', + 'i-wont-work', + ]); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted role mapping 'delete-me'", + }, + ] + `); + + expect(notifications.addDanger).toHaveBeenCalledTimes(1); + expect(notifications.addDanger.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Error deleting role mapping 'i-wont-work'", + ] + `); + }); + + it('handles errors calling the API', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockImplementation(() => { + throw new Error('AHHHHH'); + }), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(0); + + expect(notifications.addError).toHaveBeenCalledTimes(1); + expect(notifications.addError.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + [Error: AHHHHH], + Object { + "title": "Error deleting role mappings", + }, + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx new file mode 100644 index 0000000000000..2072cedeab462 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useRef, useState, ReactElement } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; + +interface Props { + roleMappingsAPI: RoleMappingsAPI; + children: (deleteMappings: DeleteRoleMappings) => ReactElement; +} + +export type DeleteRoleMappings = ( + roleMappings: RoleMapping[], + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = (deletedRoleMappings: string[]) => void; + +export const DeleteProvider: React.FunctionComponent = ({ roleMappingsAPI, children }) => { + const [roleMappings, setRoleMappings] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteInProgress, setIsDeleteInProgress] = useState(false); + + const onSuccessCallback = useRef(null); + + const deleteRoleMappingsPrompt: DeleteRoleMappings = ( + roleMappingsToDelete, + onSuccess = () => undefined + ) => { + if (!roleMappingsToDelete || !roleMappingsToDelete.length) { + throw new Error('No Role Mappings specified for delete'); + } + setIsModalOpen(true); + setRoleMappings(roleMappingsToDelete); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + setRoleMappings([]); + }; + + const deleteRoleMappings = async () => { + let result; + + setIsDeleteInProgress(true); + + try { + result = await roleMappingsAPI.deleteRoleMappings(roleMappings.map(rm => rm.name)); + } catch (e) { + toastNotifications.addError(e, { + title: i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.unknownError', + { + defaultMessage: 'Error deleting role mappings', + } + ), + }); + setIsDeleteInProgress(false); + return; + } + + setIsDeleteInProgress(false); + + closeModal(); + + const successfulDeletes = result.filter(res => res.success); + const erroredDeletes = result.filter(res => !res.success); + + // Surface success notifications + if (successfulDeletes.length > 0) { + const hasMultipleSuccesses = successfulDeletes.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.successMultipleNotificationTitle', + { + defaultMessage: 'Deleted {count} role mappings', + values: { count: successfulDeletes.length }, + } + ) + : i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.successSingleNotificationTitle', + { + defaultMessage: "Deleted role mapping '{name}'", + values: { name: successfulDeletes[0].name }, + } + ); + toastNotifications.addSuccess({ + title: successMessage, + 'data-test-subj': 'deletedRoleMappingSuccessToast', + }); + if (onSuccessCallback.current) { + onSuccessCallback.current(successfulDeletes.map(({ name }) => name)); + } + } + + // Surface error notifications + if (erroredDeletes.length > 0) { + const hasMultipleErrors = erroredDeletes.length > 1; + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.errorMultipleNotificationTitle', + { + defaultMessage: 'Error deleting {count} role mappings', + values: { + count: erroredDeletes.length, + }, + } + ) + : i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.errorSingleNotificationTitle', + { + defaultMessage: "Error deleting role mapping '{name}'", + values: { name: erroredDeletes[0].name }, + } + ); + toastNotifications.addDanger(errorMessage); + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + const isSingle = roleMappings.length === 1; + + return ( + + + {!isSingle ? ( + +

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

+
    + {roleMappings.map(({ name }) => ( +
  • {name}
  • + ))} +
+
+ ) : null} +
+
+ ); + }; + + return ( + + {children(deleteRoleMappingsPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/typings/encode_uri_query.d.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts similarity index 54% rename from x-pack/typings/encode_uri_query.d.ts rename to x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts index e1ab5f4a70abf..7e8b5a99c3bf5 100644 --- a/x-pack/typings/encode_uri_query.d.ts +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -declare module 'encode-uri-query' { - function encodeUriQuery(query: string, usePercentageSpace?: boolean): string; - // eslint-disable-next-line import/no-default-export - export default encodeUriQuery; -} +export { DeleteProvider } from './delete_provider'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts new file mode 100644 index 0000000000000..315c1f7ec2baf --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './delete_provider'; +export * from './no_compatible_realms'; +export * from './permission_denied'; +export * from './section_loading'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts new file mode 100644 index 0000000000000..fb2e5b40c1941 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NoCompatibleRealms } from './no_compatible_realms'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx new file mode 100644 index 0000000000000..969832b3ecbae --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { documentationLinks } from '../../services/documentation_links'; + +export const NoCompatibleRealms: React.FunctionComponent = () => ( + + } + color="warning" + iconType="alert" + > + + + + ), + }} + /> + +); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts new file mode 100644 index 0000000000000..8b0bc67f3f777 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PermissionDenied } from './permission_denied'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx new file mode 100644 index 0000000000000..1a32645eaedb9 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const PermissionDenied = () => ( + + + + + + } + body={ +

+ +

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

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

+
+ +

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

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

+ +

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

+ +

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

+ +

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

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

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

+ +

+
+ + + +

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

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

+ +

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

+ +

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

+ {ruleTypeSelector} + {confirmChangeModal} +

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

+ +

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

+ +

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

+ +

+
+ +

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

+
+
+ + + + + +
+ + + {!this.state.hasCompatibleRealms && ( + <> + + + + )} + {this.renderTable()} + + +
+ ); + } + + private renderTable = () => { + const { roleMappings, selectedItems, loadState } = this.state; + + const message = + loadState === 'loadingTable' ? ( + + ) : ( + undefined + ); + + const sorting = { + sort: { + field: 'name', + direction: 'asc' as any, + }, + }; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50], + }; + + const selection = { + onSelectionChange: (newSelectedItems: RoleMapping[]) => { + this.setState({ + selectedItems: newSelectedItems, + }); + }, + }; + + const search = { + toolsLeft: selectedItems.length ? ( + + {deleteRoleMappingsPrompt => { + return ( + deleteRoleMappingsPrompt(selectedItems, this.onRoleMappingsDeleted)} + color="danger" + data-test-subj="bulkDeleteActionButton" + > + + + ); + }} + + ) : ( + undefined + ), + toolsRight: ( + this.reloadRoleMappings()} + data-test-subj="reloadButton" + > + + + ), + box: { + incremental: true, + }, + filters: undefined, + }; + + return ( + { + return { + 'data-test-subj': 'roleMappingRow', + }; + }} + /> + ); + }; + + private getColumnConfig = () => { + const config = [ + { + field: 'name', + name: i18n.translate('xpack.security.management.roleMappings.nameColumnName', { + defaultMessage: 'Name', + }), + sortable: true, + render: (roleMappingName: string) => { + return ( + + {roleMappingName} + + ); + }, + }, + { + field: 'roles', + name: i18n.translate('xpack.security.management.roleMappings.rolesColumnName', { + defaultMessage: 'Roles', + }), + sortable: true, + render: (entry: any, record: RoleMapping) => { + const { roles = [], role_templates: roleTemplates = [] } = record; + if (roleTemplates.length > 0) { + return ( + + {i18n.translate('xpack.security.management.roleMappings.roleTemplates', { + defaultMessage: + '{templateCount, plural, one{# role template} other {# role templates}} defined', + values: { + templateCount: roleTemplates.length, + }, + })} + + ); + } + const roleLinks = roles.map((rolename, index) => { + return ( + + {rolename} + {index === roles.length - 1 ? null : ', '} + + ); + }); + return
{roleLinks}
; + }, + }, + { + field: 'enabled', + name: i18n.translate('xpack.security.management.roleMappings.enabledColumnName', { + defaultMessage: 'Enabled', + }), + sortable: true, + render: (enabled: boolean) => { + if (enabled) { + return ( + + + + ); + } + + return ( + + + + ); + }, + }, + { + name: i18n.translate('xpack.security.management.roleMappings.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (record: RoleMapping) => { + return ( + + + + ); + }, + }, + { + render: (record: RoleMapping) => { + return ( + + + + {deleteRoleMappingPrompt => { + return ( + + + deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted) + } + /> + + ); + }} + + + + ); + }, + }, + ], + }, + ]; + return config; + }; + + private onRoleMappingsDeleted = (roleMappings: string[]): void => { + if (roleMappings.length) { + this.reloadRoleMappings(); + } + }; + + private async checkPrivileges() { + try { + const { + canManageRoleMappings, + hasCompatibleRealms, + } = await this.props.roleMappingsAPI.checkRoleMappingFeatures(); + + this.setState({ + loadState: canManageRoleMappings ? this.state.loadState : 'permissionDenied', + hasCompatibleRealms, + }); + + if (canManageRoleMappings) { + this.loadRoleMappings(); + } + } catch (e) { + this.setState({ error: e, loadState: 'finished' }); + } + } + + private reloadRoleMappings = () => { + this.setState({ roleMappings: [], loadState: 'loadingTable' }); + this.loadRoleMappings(); + }; + + private loadRoleMappings = async () => { + try { + const roleMappings = await this.props.roleMappingsAPI.getRoleMappings(); + this.setState({ roleMappings }); + } catch (e) { + this.setState({ error: e }); + } + + this.setState({ loadState: 'finished' }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx new file mode 100644 index 0000000000000..9e925d0fa6dc0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import routes from 'ui/routes'; +import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; +import { RoleMappingsAPI } from '../../../../lib/role_mappings_api'; +// @ts-ignore +import template from './role_mappings.html'; +import { ROLE_MAPPINGS_PATH } from '../../management_urls'; +import { getRoleMappingBreadcrumbs } from '../../breadcrumbs'; +import { RoleMappingsGridPage } from './components'; + +routes.when(ROLE_MAPPINGS_PATH, { + template, + k7Breadcrumbs: getRoleMappingBreadcrumbs, + controller($scope) { + $scope.$$postDigest(() => { + const domNode = document.getElementById('roleMappingsGridReactRoot'); + + render( + + + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html new file mode 100644 index 0000000000000..cff3b821d132c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts new file mode 100644 index 0000000000000..36351f49890a1 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +class DocumentationLinksService { + private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; + + public getRoleMappingDocUrl() { + return `${this.esDocBasePath}/mapping-roles.html`; + } + + public getRoleMappingAPIDocUrl() { + return `${this.esDocBasePath}/security-api-put-role-mapping.html`; + } + + public getRoleMappingTemplateDocUrl() { + return `${this.esDocBasePath}/security-api-put-role-mapping.html#_role_templates`; + } + + public getRoleMappingFieldRulesDocUrl() { + return `${this.esDocBasePath}/role-mapping-resources.html#mapping-roles-rule-field`; + } +} + +export const documentationLinks = new DocumentationLinksService(); diff --git a/x-pack/legacy/plugins/siem/cypress/README.md b/x-pack/legacy/plugins/siem/cypress/README.md index fb2b6cd2e3fd3..c9e0d4e18f78f 100644 --- a/x-pack/legacy/plugins/siem/cypress/README.md +++ b/x-pack/legacy/plugins/siem/cypress/README.md @@ -51,10 +51,23 @@ export const USERNAME = '[data-test-subj="loginUsername"]'; We prefer not to mock API responses in most of our smoke tests, but sometimes it's necessary because a test must assert that a specific value is rendered, and it's not possible to derive that value based on the data in the -envrionment where tests are running. +environment where tests are running. Mocked responses API from the server are located in `siem/cypress/fixtures`. +## Speeding up test execution time + +Loading the web page takes a big amount of time, in order to minimize that impact, the following points should be +taken into consideration until another solution is implemented: + +- Don't refresh the page for every test to clean the state of it. +- Instead, group the tests that are similar in different contexts. +- For every context login only once, clean the state between tests if needed without re-loading the page. +- All tests in a spec file must be order-independent. + - If you need to reload the page to make the tests order-independent, consider to create a new context. + +Remember that minimizing the number of times the web page is loaded, we minimize as well the execution time. + ## Authentication When running tests, there are two ways to specify the credentials used to diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts index 0e3717feef7ad..6f7906d7fd791 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/events_viewer/selectors.ts @@ -19,6 +19,8 @@ export const HEADER_SUBTITLE = `${EVENTS_VIEWER_PANEL} [data-test-subj="header-p /** The inspect query modal */ export const INSPECT_MODAL = '[data-test-subj="modal-inspect-euiModal"]'; +export const CLOSE_MODAL = '[data-test-subj="modal-inspect-close"]'; + /** The inspect query button that launches the inspect query modal */ export const INSPECT_QUERY = `${EVENTS_VIEWER_PANEL} [data-test-subj="inspect-icon-button"]`; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts index 79169d3769a78..1450ee8dc8abf 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/events_viewer/events_viewer.spec.ts @@ -18,6 +18,7 @@ import { filterSearchBar, } from '../../lib/events_viewer/helpers'; import { + CLOSE_MODAL, EVENTS_VIEWER_PANEL, HEADER_SUBTITLE, INSPECT_MODAL, @@ -39,162 +40,162 @@ const defaultHeadersInDefaultEcsCategory = [ ]; describe('Events Viewer', () => { - beforeEach(() => { - loginAndWaitForPage(HOSTS_PAGE); - - clickEventsTab(); - }); - - it('renders the fields browser with the expected title when the Events Viewer Fields Browser button is clicked', () => { - openEventsViewerFieldsBrowser(); - - cy.get(FIELDS_BROWSER_TITLE) - .invoke('text') - .should('eq', 'Customize Columns'); - }); + context('Fields rendering', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); + }); - it('closes the fields browser when the user clicks outside of it', () => { - openEventsViewerFieldsBrowser(); + beforeEach(() => { + openEventsViewerFieldsBrowser(); + }); - clickOutsideFieldsBrowser(); + afterEach(() => { + clickOutsideFieldsBrowser(); + cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); + }); - cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); - }); + it('renders the fields browser with the expected title when the Events Viewer Fields Browser button is clicked', () => { + cy.get(FIELDS_BROWSER_TITLE) + .invoke('text') + .should('eq', 'Customize Columns'); + }); - it('displays the `default ECS` category (by default)', () => { - openEventsViewerFieldsBrowser(); + it('displays the `default ECS` category (by default)', () => { + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) + .invoke('text') + .should('eq', 'default ECS'); + }); - cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) - .invoke('text') - .should('eq', 'default ECS'); + it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => { + defaultHeadersInDefaultEcsCategory.forEach(header => + cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') + ); + }); }); - it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => { - openEventsViewerFieldsBrowser(); - - defaultHeadersInDefaultEcsCategory.forEach(header => - cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') - ); - }); + context('Events viewer query modal', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); + }); - it('removes the message field from the timeline when the user un-checks the field', () => { - const toggleField = 'message'; + after(() => { + cy.get(CLOSE_MODAL).click(); + cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('not.exist'); + }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist'); + it('launches the inspect query modal when the inspect button is clicked', () => { + // wait for data to load + cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) + .should('exist') + .invoke('text', { timeout: DEFAULT_TIMEOUT }) + .should('not.equal', '0'); - openEventsViewerFieldsBrowser(); + cy.get(INSPECT_QUERY, { timeout: DEFAULT_TIMEOUT }) + .should('exist') + .trigger('mousemove', { force: true }) + .click({ force: true }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).uncheck({ - force: true, + cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('exist'); }); - - clickOutsideFieldsBrowser(); - - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); }); - it('filters the events by applying filter criteria from the search bar at the top of the page', () => { - const filterInput = '4bf34c1c-eaa9-46de-8921-67a4ccc49829'; // this will never match real data - - cy.get(HEADER_SUBTITLE) - .invoke('text') - .then(text1 => { - cy.get(HEADER_SUBTITLE) - .invoke('text', { timeout: DEFAULT_TIMEOUT }) - .should('not.equal', 'Showing: 0 events'); + context('Events viewer fields behaviour', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); + }); - filterSearchBar(filterInput); + beforeEach(() => { + openEventsViewerFieldsBrowser(); + }); - cy.get(HEADER_SUBTITLE) - .invoke('text') - .should(text2 => { - expect(text1).not.to.eq(text2); - }); - }); - }); + it('adds a field to the events viewer when the user clicks the checkbox', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.city_name'; - it('adds a field to the events viewer when the user clicks the checkbox', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; + filterFieldsBrowser(filterInput); - openEventsViewerFieldsBrowser(); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - filterFieldsBrowser(filterInput); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ + force: true, + }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + clickOutsideFieldsBrowser(); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ - force: true, + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'exist' + ); }); - clickOutsideFieldsBrowser(); + it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.country_name'; - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist'); - }); + filterFieldsBrowser(filterInput); - it('loads more events when the load more button is clicked', () => { - cy.get(LOCAL_EVENTS_COUNT, { timeout: DEFAULT_TIMEOUT }) - .invoke('text') - .then(text1 => { - cy.get(LOCAL_EVENTS_COUNT) - .invoke('text') - .should('equal', '25'); - - cy.get(LOAD_MORE).click({ force: true }); - - cy.get(LOCAL_EVENTS_COUNT) - .invoke('text') - .should(text2 => { - expect(text1).not.to.eq(text2); - }); - }); - }); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); - it('launches the inspect query modal when the inspect button is clicked', () => { - // wait for data to load - cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) - .should('exist') - .invoke('text', { timeout: DEFAULT_TIMEOUT }) - .should('not.equal', '0'); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ + force: true, + }); - cy.get(INSPECT_QUERY, { timeout: DEFAULT_TIMEOUT }) - .should('exist') - .trigger('mousemove', { force: true }) - .click({ force: true }); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="reset-fields"]`).click({ force: true }); - cy.get(INSPECT_MODAL, { timeout: DEFAULT_TIMEOUT }).should('exist'); + cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( + 'not.exist' + ); + }); }); - it('resets all fields in the events viewer when `Reset Fields` is clicked', () => { - const filterInput = 'host.geo.c'; - const toggleField = 'host.geo.city_name'; - - openEventsViewerFieldsBrowser(); - - filterFieldsBrowser(filterInput); - - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); - - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="field-${toggleField}-checkbox"]`).check({ - force: true, + context('Events behaviour', () => { + before(() => { + loginAndWaitForPage(HOSTS_PAGE); + clickEventsTab(); }); - clickOutsideFieldsBrowser(); + it('filters the events by applying filter criteria from the search bar at the top of the page', () => { + const filterInput = '4bf34c1c-eaa9-46de-8921-67a4ccc49829'; // this will never match real data - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should('exist'); + cy.get(HEADER_SUBTITLE) + .invoke('text') + .then(text1 => { + cy.get(HEADER_SUBTITLE) + .invoke('text', { timeout: DEFAULT_TIMEOUT }) + .should('not.equal', 'Showing: 0 events'); - openEventsViewerFieldsBrowser(); + filterSearchBar(filterInput); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="reset-fields"]`).click({ force: true }); + cy.get(HEADER_SUBTITLE) + .invoke('text') + .should(text2 => { + expect(text1).not.to.eq(text2); + }); + }); + }); - cy.get(`${EVENTS_VIEWER_PANEL} [data-test-subj="header-text-${toggleField}"]`).should( - 'not.exist' - ); + it('loads more events when the load more button is clicked', () => { + cy.get(LOCAL_EVENTS_COUNT, { timeout: DEFAULT_TIMEOUT }) + .invoke('text') + .then(text1 => { + cy.get(LOCAL_EVENTS_COUNT) + .invoke('text') + .should('equal', '25'); + + cy.get(LOAD_MORE).click({ force: true }); + + cy.get(LOCAL_EVENTS_COUNT) + .invoke('text') + .should(text2 => { + expect(text1).not.to.eq(text2); + }); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts index bb1a0379ce0ea..a549b5eec2e7c 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { OVERVIEW_PAGE, TIMELINES_PAGE } from '../../lib/urls'; +import { TIMELINES_PAGE } from '../../lib/urls'; import { NAVIGATION_HOSTS, NAVIGATION_NETWORK, @@ -14,33 +14,27 @@ import { import { loginAndWaitForPage } from '../../lib/util/helpers'; describe('top-level navigation common to all pages in the SIEM app', () => { - it('navigates to the Overview page', () => { + before(() => { loginAndWaitForPage(TIMELINES_PAGE); - + }); + it('navigates to the Overview page', () => { cy.get(NAVIGATION_OVERVIEW).click({ force: true }); - cy.url().should('include', '/siem#/overview'); }); it('navigates to the Hosts page', () => { - loginAndWaitForPage(TIMELINES_PAGE); - cy.get(NAVIGATION_HOSTS).click({ force: true }); cy.url().should('include', '/siem#/hosts'); }); it('navigates to the Network page', () => { - loginAndWaitForPage(TIMELINES_PAGE); - cy.get(NAVIGATION_NETWORK).click({ force: true }); cy.url().should('include', '/siem#/network'); }); it('navigates to the Timelines page', () => { - loginAndWaitForPage(OVERVIEW_PAGE); - cy.get(NAVIGATION_TIMELINES).click({ force: true }); cy.url().should('include', '/siem#/timelines'); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts index 73711f1434d5f..3853e703a7c07 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/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx index 04d6d94d6624d..a2a0ffdde34a5 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx @@ -20,7 +20,7 @@ import * as i18n from './translations'; const InspectContainer = styled.div<{ showInspect: boolean }>` .euiButtonIcon { - ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0')} + ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')} transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease; } `; diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 098f54640e4b2..5ed750b519cbf 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -105,7 +105,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = showInspect={false} >
` - padding-left: ${({ selected }) => (selected ? '3px' : '0px')}; +const MyEuiFlexItem = styled(EuiFlexItem)` + display: inline-block; + max-width: 296px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; -const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>` - padding-left: ${({ selected }) => (selected ? '20px' : '0px')}; +const EuiSelectableContainer = styled.div` + .euiSelectable { + .euiFormControlLayout__childrenWrapper { + display: flex; + } + } +`; + +const MyEuiFlexGroup = styled(EuiFlexGroup)` + padding 0px 4px; `; interface SearchTimelineSuperSelectProps { @@ -83,6 +95,7 @@ const SearchTimelineSuperSelectComponent: React.FC(null); const onSearchTimeline = useCallback(val => { setSearchTimelineValue(val); @@ -102,37 +115,57 @@ const SearchTimelineSuperSelectComponent: React.FC { return ( - <> - {option.checked === 'on' && } - - {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} - -
- - - {option.description != null && option.description.trim().length > 0 - ? option.description - : getEmptyTagValue()} - - - + + + + + + + + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + + + + + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + + + + + + ); }, []); - const handleTimelineChange = useCallback(options => { - const selectedTimeline = options.filter( - (option: { checked: string }) => option.checked === 'on' - ); - if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) { - onTimelineChange( - isEmpty(selectedTimeline[0].title) - ? i18nTimeline.UNTITLED_TIMELINE - : selectedTimeline[0].title, - selectedTimeline[0].id + const handleTimelineChange = useCallback( + options => { + const selectedTimeline = options.filter( + (option: { checked: string }) => option.checked === 'on' ); - } - setIsPopoverOpen(false); - }, []); + if (selectedTimeline != null && selectedTimeline.length > 0) { + onTimelineChange( + isEmpty(selectedTimeline[0].title) + ? i18nTimeline.UNTITLED_TIMELINE + : selectedTimeline[0].title, + selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id + ); + } + setIsPopoverOpen(false); + }, + [onTimelineChange] + ); const handleOnScroll = useCallback( ( @@ -187,6 +220,29 @@ const SearchTimelineSuperSelectComponent: React.FC + searchRef != null ? ( + + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + ) : null, + [searchRef, onlyFavorites, handleOnToggleOnlyFavorites] + ); + return ( {({ timelines, loading, totalCount }) => ( - <> - - - - - {i18nTimeline.ONLY_FAVORITES} - - - - - + { + setSearchRef(ref); + }, }} singleSelection={true} options={[ @@ -249,6 +293,7 @@ const SearchTimelineSuperSelectComponent: React.FC ({ description: t.description, + favorite: !isEmpty(t.favorite), label: t.title, id: t.savedObjectId, key: `${t.title}-${index}`, @@ -261,11 +306,12 @@ const SearchTimelineSuperSelectComponent: React.FC ( <> {search} + {favoritePortal} {list} )} - + )} 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 f9611995cdb04..b69a8de29e047 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -15,9 +15,13 @@ import { NewRule, Rule, FetchRuleProps, + BasicFetchProps, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../common/constants'; +import { + DETECTION_ENGINE_RULES_URL, + DETECTION_ENGINE_PREPACKAGED_URL, +} from '../../../../common/constants'; /** * Add provided Rule @@ -199,3 +203,22 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise>(response => response.json()) ); }; + +/** + * Create Prepackaged Rules + * + * @param signal AbortSignal for cancelling request + */ +export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => { + const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_PREPACKAGED_URL}`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal, + }); + await throwIfNotOk(response); + return true; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 655299c4a2a34..a329d96d444aa 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 @@ -132,3 +132,7 @@ export interface DeleteRulesProps { export interface DuplicateRulesProps { rules: Rules; } + +export interface BasicFetchProps { + signal: AbortSignal; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts index 5b5dc9e9699fe..2b8f54e5438df 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts @@ -12,3 +12,24 @@ export const SIGNAL_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to query signals', } ); + +export const PRIVILEGE_FETCH_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorFetchingSignalsDescription', + { + defaultMessage: 'Failed to query signals', + } +); + +export const SIGNAL_GET_NAME_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorGetSignalDescription', + { + defaultMessage: 'Failed to get signal index name', + } +); + +export const SIGNAL_POST_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorPostSignalDescription', + { + defaultMessage: 'Failed to create signal index', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx index aa66df53d9fd9..792ff29ad2488 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx @@ -6,10 +6,18 @@ import { useEffect, useState } from 'react'; +import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; +import { useStateToaster } from '../../../components/toasters'; import { getUserPrivilege } from './api'; +import * as i18n from './translations'; -type Return = [boolean, boolean | null, boolean | null]; - +interface Return { + loading: boolean; + isAuthenticated: boolean | null; + hasIndexManage: boolean | null; + hasManageApiKey: boolean | null; + hasIndexWrite: boolean | null; +} /** * Hook to get user privilege from * @@ -17,7 +25,10 @@ type Return = [boolean, boolean | null, boolean | null]; export const usePrivilegeUser = (): Return => { const [loading, setLoading] = useState(true); const [isAuthenticated, setAuthenticated] = useState(null); - const [hasWrite, setHasWrite] = useState(null); + const [hasIndexManage, setHasIndexManage] = useState(null); + const [hasIndexWrite, setHasIndexWrite] = useState(null); + const [hasManageApiKey, setHasManageApiKey] = useState(null); + const [, dispatchToaster] = useStateToaster(); useEffect(() => { let isSubscribed = true; @@ -34,13 +45,21 @@ export const usePrivilegeUser = (): Return => { setAuthenticated(privilege.isAuthenticated); if (privilege.index != null && Object.keys(privilege.index).length > 0) { const indexName = Object.keys(privilege.index)[0]; - setHasWrite(privilege.index[indexName].create_index); + setHasIndexManage(privilege.index[indexName].manage); + setHasIndexWrite(privilege.index[indexName].write); + setHasManageApiKey( + privilege.cluster.manage_security || + privilege.cluster.manage_api_key || + privilege.cluster.manage_own_api_key + ); } } } catch (error) { if (isSubscribed) { setAuthenticated(false); - setHasWrite(false); + setHasIndexManage(false); + setHasIndexWrite(false); + errorToToaster({ title: i18n.PRIVILEGE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -55,5 +74,5 @@ export const usePrivilegeUser = (): Return => { }; }, []); - return [loading, isAuthenticated, hasWrite]; + return { loading, isAuthenticated, hasIndexManage, hasManageApiKey, hasIndexWrite }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx index 1ff4422cf6411..189d8a1bf3f75 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx @@ -8,9 +8,10 @@ import { useEffect, useState, useRef } from 'react'; import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../../components/toasters'; +import { createPrepackagedRules } from '../rules'; import { createSignalIndex, getSignalIndex } from './api'; import * as i18n from './translations'; -import { PostSignalError } from './types'; +import { PostSignalError, SignalIndexError } from './types'; type Func = () => void; @@ -40,11 +41,15 @@ export const useSignalIndex = (): Return => { if (isSubscribed && signal != null) { setSignalIndexName(signal.name); setSignalIndexExists(true); + createPrepackagedRules({ signal: abortCtrl.signal }); } } catch (error) { if (isSubscribed) { setSignalIndexName(null); setSignalIndexExists(false); + if (error instanceof SignalIndexError && error.statusCode !== 404) { + errorToToaster({ title: i18n.SIGNAL_GET_NAME_FAILURE, error, dispatchToaster }); + } } } if (isSubscribed) { @@ -69,7 +74,7 @@ export const useSignalIndex = (): Return => { } else { setSignalIndexName(null); setSignalIndexExists(false); - errorToToaster({ title: i18n.SIGNAL_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.SIGNAL_POST_FAILURE, error, dispatchToaster }); } } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx new file mode 100644 index 0000000000000..1950531998450 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +const NoWriteSignalsCallOutComponent = () => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + +

{i18n.NO_WRITE_SIGNALS_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const NoWriteSignalsCallOut = memo(NoWriteSignalsCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts new file mode 100644 index 0000000000000..065d775e1dc6a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const NO_WRITE_SIGNALS_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.noWriteSignalsCallOutTitle', + { + defaultMessage: 'Signals index permissions required', + } +); + +export const NO_WRITE_SIGNALS_CALLOUT_MSG = i18n.translate( + 'xpack.siem.detectionEngine.noWriteSignalsCallOutMsg', + { + defaultMessage: + 'You are currently missing the required permissions to update signals. Please contact your administrator for further assistance.', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.detectionEngine.dismissNoWriteSignalButton', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx index 1a7ad5822a246..83b6ba690ec5b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx @@ -168,55 +168,66 @@ export const requiredFieldsForActions = [ ]; export const getSignalsActions = ({ + canUserCRUD, + hasIndexWrite, setEventsLoading, setEventsDeleted, createTimeline, status, }: { + canUserCRUD: boolean; + hasIndexWrite: boolean; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; createTimeline: CreateTimeline; status: 'open' | 'closed'; -}): TimelineAction[] => [ - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - sendSignalsToTimelineAction({ createTimeline, data: [data] })} - iconType="tableDensityNormal" - aria-label="Next" - /> - - ), - id: 'sendSignalToTimeline', - width: 26, - }, - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - - updateSignalStatusAction({ - signalIds: [eventId], - status, - setEventsLoading, - setEventsDeleted, - }) - } - iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} - aria-label="Next" - /> - - ), - id: 'updateSignalStatus', - width: 26, - }, -]; +}): TimelineAction[] => { + const actions = [ + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + sendSignalsToTimelineAction({ createTimeline, data: [data] })} + iconType="tableDensityNormal" + aria-label="Next" + /> + + ), + id: 'sendSignalToTimeline', + width: 26, + }, + ]; + return canUserCRUD && hasIndexWrite + ? [ + ...actions, + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + + updateSignalStatusAction({ + signalIds: [eventId], + status, + setEventsLoading, + setEventsDeleted, + }) + } + iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} + aria-label="Next" + /> + + ), + id: 'updateSignalStatus', + width: 26, + }, + ] + : actions; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index 47a78482cfb6e..d149eb700ad03 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; @@ -46,6 +47,8 @@ import { useFetchIndexPatterns } from '../../../../containers/detection_engine/r import { InputsRange } from '../../../../store/inputs/model'; import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { HeaderSection } from '../../../../components/header_section'; + const SIGNALS_PAGE_TIMELINE_ID = 'signals-page'; interface ReduxProps { @@ -88,8 +91,11 @@ interface DispatchProps { } interface OwnProps { + canUserCRUD: boolean; defaultFilters?: esFilters.Filter[]; + hasIndexWrite: boolean; from: number; + loading: boolean; signalsIndex: string; to: number; } @@ -98,6 +104,7 @@ type SignalsTableComponentProps = OwnProps & ReduxProps & DispatchProps; export const SignalsTableComponent = React.memo( ({ + canUserCRUD, createTimeline, clearEventsDeleted, clearEventsLoading, @@ -106,7 +113,9 @@ export const SignalsTableComponent = React.memo( from, globalFilters, globalQuery, + hasIndexWrite, isSelectAllChecked, + loading, loadingEventIds, removeTimelineLinkTo, selectedEventIds, @@ -228,8 +237,10 @@ export const SignalsTableComponent = React.memo( (totalCount: number) => { return ( 0} clearSelection={clearSelectionCallback} + hasIndexWrite={hasIndexWrite} isFilteredToOpen={filterGroup === FILTER_OPEN} selectAll={selectAllCallback} selectedEventIds={selectedEventIds} @@ -241,6 +252,8 @@ export const SignalsTableComponent = React.memo( ); }, [ + canUserCRUD, + hasIndexWrite, clearSelectionCallback, filterGroup, loadingEventIds.length, @@ -254,12 +267,14 @@ export const SignalsTableComponent = React.memo( const additionalActions = useMemo( () => getSignalsActions({ + canUserCRUD, + hasIndexWrite, createTimeline: createTimelineCallback, setEventsLoading: setEventsLoadingCallback, setEventsDeleted: setEventsDeletedCallback, status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN, }), - [createTimelineCallback, filterGroup] + [canUserCRUD, createTimelineCallback, filterGroup] ); const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]); @@ -279,11 +294,20 @@ export const SignalsTableComponent = React.memo( queryFields: requiredFieldsForActions, timelineActions: additionalActions, title: i18n.SIGNALS_TABLE_TITLE, - selectAll, + selectAll: canUserCRUD ? selectAll : false, }), - [additionalActions, selectAll] + [additionalActions, canUserCRUD, selectAll] ); + if (loading) { + return ( + + + + + ); + } + return ( >; + updateSignalsStatus: UpdateSignalsStatus; + sendSignalsToTimeline: SendSignalsToTimeline; + closePopover: () => void; + isFilteredToOpen: boolean; +} /** * Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel * @@ -22,15 +31,15 @@ import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group'; * @param closePopover * @param isFilteredToOpen currently selected filter options */ -export const getBatchItems = ( - areEventsLoading: boolean, - allEventsSelected: boolean, - selectedEventIds: Readonly>, - updateSignalsStatus: UpdateSignalsStatus, - sendSignalsToTimeline: SendSignalsToTimeline, - closePopover: () => void, - isFilteredToOpen: boolean -) => { +export const getBatchItems = ({ + areEventsLoading, + allEventsSelected, + selectedEventIds, + updateSignalsStatus, + sendSignalsToTimeline, + closePopover, + isFilteredToOpen, +}: GetBatchItems) => { const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0; const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1; const filterString = isFilteredToOpen diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx index f80de053b59bd..e28fb3e06870e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx @@ -22,6 +22,8 @@ import { TimelineNonEcsData } from '../../../../../graphql/types'; import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types'; interface SignalsUtilityBarProps { + canUserCRUD: boolean; + hasIndexWrite: boolean; areEventsLoading: boolean; clearSelection: () => void; isFilteredToOpen: boolean; @@ -34,6 +36,8 @@ interface SignalsUtilityBarProps { } const SignalsUtilityBarComponent: React.FC = ({ + canUserCRUD, + hasIndexWrite, areEventsLoading, clearSelection, totalCount, @@ -49,15 +53,15 @@ const SignalsUtilityBarComponent: React.FC = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), [ @@ -66,6 +70,7 @@ const SignalsUtilityBarComponent: React.FC = ({ updateSignalsStatus, sendSignalsToTimeline, isFilteredToOpen, + hasIndexWrite, ] ); @@ -83,7 +88,7 @@ const SignalsUtilityBarComponent: React.FC = ({ - {totalCount > 0 && ( + {canUserCRUD && hasIndexWrite && ( <> {i18n.SELECTED_SIGNALS( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx new file mode 100644 index 0000000000000..bbaccb7882484 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { noop } from 'lodash/fp'; +import React, { useEffect, useReducer, Dispatch, createContext, useContext } from 'react'; + +import { usePrivilegeUser } from '../../../../containers/detection_engine/signals/use_privilege_user'; +import { useSignalIndex } from '../../../../containers/detection_engine/signals/use_signal_index'; +import { useKibana } from '../../../../lib/kibana'; + +export interface State { + canUserCRUD: boolean | null; + hasIndexManage: boolean | null; + hasIndexWrite: boolean | null; + hasManageApiKey: boolean | null; + isSignalIndexExists: boolean | null; + isAuthenticated: boolean | null; + loading: boolean; + signalIndexName: string | null; +} + +const initialState: State = { + canUserCRUD: null, + hasIndexManage: null, + hasIndexWrite: null, + hasManageApiKey: null, + isSignalIndexExists: null, + isAuthenticated: null, + loading: true, + signalIndexName: null, +}; + +export type Action = + | { type: 'updateLoading'; loading: boolean } + | { + type: 'updateHasManageApiKey'; + hasManageApiKey: boolean | null; + } + | { + type: 'updateHasIndexManage'; + hasIndexManage: boolean | null; + } + | { + type: 'updateHasIndexWrite'; + hasIndexWrite: boolean | null; + } + | { + type: 'updateIsSignalIndexExists'; + isSignalIndexExists: boolean | null; + } + | { + type: 'updateIsAuthenticated'; + isAuthenticated: boolean | null; + } + | { + type: 'updateCanUserCRUD'; + canUserCRUD: boolean | null; + } + | { + type: 'updateSignalIndexName'; + signalIndexName: string | null; + }; + +export const userInfoReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'updateLoading': { + return { + ...state, + loading: action.loading, + }; + } + case 'updateHasIndexManage': { + return { + ...state, + hasIndexManage: action.hasIndexManage, + }; + } + case 'updateHasIndexWrite': { + return { + ...state, + hasIndexWrite: action.hasIndexWrite, + }; + } + case 'updateHasManageApiKey': { + return { + ...state, + hasManageApiKey: action.hasManageApiKey, + }; + } + case 'updateIsSignalIndexExists': { + return { + ...state, + isSignalIndexExists: action.isSignalIndexExists, + }; + } + case 'updateIsAuthenticated': { + return { + ...state, + isAuthenticated: action.isAuthenticated, + }; + } + case 'updateCanUserCRUD': { + return { + ...state, + canUserCRUD: action.canUserCRUD, + }; + } + case 'updateSignalIndexName': { + return { + ...state, + signalIndexName: action.signalIndexName, + }; + } + default: + return state; + } +}; + +const StateUserInfoContext = createContext<[State, Dispatch]>([initialState, () => noop]); + +const useUserData = () => useContext(StateUserInfoContext); + +interface ManageUserInfoProps { + children: React.ReactNode; +} + +export const ManageUserInfo = ({ children }: ManageUserInfoProps) => ( + + {children} + +); + +export const useUserInfo = (): State => { + const [ + { + canUserCRUD, + hasIndexManage, + hasIndexWrite, + hasManageApiKey, + isSignalIndexExists, + isAuthenticated, + loading, + signalIndexName, + }, + dispatch, + ] = useUserData(); + const { + loading: privilegeLoading, + isAuthenticated: isApiAuthenticated, + hasIndexManage: hasApiIndexManage, + hasIndexWrite: hasApiIndexWrite, + hasManageApiKey: hasApiManageApiKey, + } = usePrivilegeUser(); + const [ + indexNameLoading, + isApiSignalIndexExists, + apiSignalIndexName, + createSignalIndex, + ] = useSignalIndex(); + + const uiCapabilities = useKibana().services.application.capabilities; + const capabilitiesCanUserCRUD: boolean = + typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false; + + useEffect(() => { + if (loading !== privilegeLoading || indexNameLoading) { + dispatch({ type: 'updateLoading', loading: privilegeLoading || indexNameLoading }); + } + }, [loading, privilegeLoading, indexNameLoading]); + + useEffect(() => { + if (hasIndexManage !== hasApiIndexManage && hasApiIndexManage != null) { + dispatch({ type: 'updateHasIndexManage', hasIndexManage: hasApiIndexManage }); + } + }, [hasIndexManage, hasApiIndexManage]); + + useEffect(() => { + if (hasIndexWrite !== hasApiIndexWrite && hasApiIndexWrite != null) { + dispatch({ type: 'updateHasIndexWrite', hasIndexWrite: hasApiIndexWrite }); + } + }, [hasIndexWrite, hasApiIndexWrite]); + + useEffect(() => { + if (hasManageApiKey !== hasApiManageApiKey && hasApiManageApiKey != null) { + dispatch({ type: 'updateHasManageApiKey', hasManageApiKey: hasApiManageApiKey }); + } + }, [hasManageApiKey, hasApiManageApiKey]); + + useEffect(() => { + if (isSignalIndexExists !== isApiSignalIndexExists && isApiSignalIndexExists != null) { + dispatch({ type: 'updateIsSignalIndexExists', isSignalIndexExists: isApiSignalIndexExists }); + } + }, [isSignalIndexExists, isApiSignalIndexExists]); + + useEffect(() => { + if (isAuthenticated !== isApiAuthenticated && isApiAuthenticated != null) { + dispatch({ type: 'updateIsAuthenticated', isAuthenticated: isApiAuthenticated }); + } + }, [isAuthenticated, isApiAuthenticated]); + + useEffect(() => { + if (canUserCRUD !== capabilitiesCanUserCRUD && capabilitiesCanUserCRUD != null) { + dispatch({ type: 'updateCanUserCRUD', canUserCRUD: capabilitiesCanUserCRUD }); + } + }, [canUserCRUD, capabilitiesCanUserCRUD]); + + useEffect(() => { + if (signalIndexName !== apiSignalIndexName && apiSignalIndexName != null) { + dispatch({ type: 'updateSignalIndexName', signalIndexName: apiSignalIndexName }); + } + }, [signalIndexName, apiSignalIndexName]); + + useEffect(() => { + if ( + isAuthenticated && + hasIndexManage && + isSignalIndexExists != null && + !isSignalIndexExists && + createSignalIndex != null + ) { + createSignalIndex(); + } + }, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasIndexManage]); + + return { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasIndexManage, + hasIndexWrite, + hasManageApiKey, + signalIndexName, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 2a91a559ec0e4..e638cf89e77bb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiLoadingContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; import React, { useCallback } from 'react'; import { StickyContainer } from 'react-sticky'; @@ -18,30 +18,23 @@ import { GlobalTime } from '../../containers/global_time'; import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source'; import { SpyRoute } from '../../utils/route/spy_routes'; -import { SignalsTable } from './components/signals'; -import * as signalsI18n from './components/signals/translations'; -import { SignalsHistogramPanel } from './components/signals_histogram_panel'; import { Query } from '../../../../../../../src/plugins/data/common/query'; import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; -import { inputsSelectors } from '../../store/inputs'; import { State } from '../../store'; +import { inputsSelectors } from '../../store/inputs'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { InputsModelId } from '../../store/inputs/constants'; import { InputsRange } from '../../store/inputs/model'; -import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; import { useSignalInfo } from './components/signals_info'; +import { SignalsTable } from './components/signals'; +import { NoWriteSignalsCallOut } from './components/no_write_signals_callout'; +import { SignalsHistogramPanel } from './components/signals_histogram_panel'; +import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; +import { useUserInfo } from './components/user_info'; import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; -import { HeaderSection } from '../../components/header_section'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; -import { InputsModelId } from '../../store/inputs/constants'; - -interface OwnProps { - loading: boolean; - isSignalIndexExists: boolean | null; - isUserAuthenticated: boolean | null; - signalsIndex: string | null; -} interface ReduxProps { filters: esFilters.Filter[]; @@ -56,18 +49,19 @@ export interface DispatchProps { }>; } -type DetectionEngineComponentProps = OwnProps & ReduxProps & DispatchProps; - -export const DetectionEngineComponent = React.memo( - ({ - filters, - loading, - isSignalIndexExists, - isUserAuthenticated, - query, - setAbsoluteRangeDatePicker, - signalsIndex, - }) => { +type DetectionEngineComponentProps = ReduxProps & DispatchProps; + +const DetectionEngineComponent = React.memo( + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated: isUserAuthenticated, + canUserCRUD, + signalIndexName, + hasIndexWrite, + } = useUserInfo(); + const [lastSignals] = useSignalInfo({}); const updateDateRangeCallback = useCallback( @@ -95,6 +89,7 @@ export const DetectionEngineComponent = React.memo + {hasIndexWrite != null && !hasIndexWrite && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -102,7 +97,6 @@ export const DetectionEngineComponent = React.memo - - {!loading ? ( - isSignalIndexExists && ( - - ) - ) : ( - - - - - )} + )} @@ -160,7 +152,6 @@ export const DetectionEngineComponent = React.memo - ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index c32cab7f933b2..c4e83429aebdb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -4,70 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; -import { useSignalIndex } from '../../containers/detection_engine/signals/use_signal_index'; -import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user'; - import { CreateRuleComponent } from './rules/create'; import { DetectionEngine } from './detection_engine'; import { EditRuleComponent } from './rules/edit'; import { RuleDetails } from './rules/details'; import { RulesComponent } from './rules'; +import { ManageUserInfo } from './components/user_info'; const detectionEnginePath = `/:pageName(detection-engine)`; type Props = Partial> & { url: string }; -export const DetectionEngineContainer = React.memo(() => { - const [privilegeLoading, isAuthenticated, hasWrite] = usePrivilegeUser(); - const [ - indexNameLoading, - isSignalIndexExists, - signalIndexName, - createSignalIndex, - ] = useSignalIndex(); - - useEffect(() => { - if ( - isAuthenticated && - hasWrite && - isSignalIndexExists != null && - !isSignalIndexExists && - createSignalIndex != null - ) { - createSignalIndex(); - } - }, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasWrite]); - - return ( +export const DetectionEngineContainer = React.memo(() => ( + - + + + + + + + + + + + + + - {isSignalIndexExists && isAuthenticated && ( - <> - - - - - - - - - - - - - - )} - ( @@ -75,6 +43,6 @@ export const DetectionEngineContainer = React.memo(() => { )} /> - ); -}); + +)); DetectionEngineContainer.displayName = 'DetectionEngineContainer'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 42c4bb1d0ef95..95b9c9324894f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -68,111 +68,121 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ }, ]; +type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType; + // Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes? export const getColumns = ( dispatch: React.Dispatch, - history: H.History -): Array | EuiTableActionsColumnType> => [ - { - field: 'rule', - name: i18n.COLUMN_RULE, - render: (value: TableData['rule']) => {value.name}, - truncateText: true, - width: '24%', - }, - { - field: 'method', - name: i18n.COLUMN_METHOD, - truncateText: true, - }, - { - field: 'severity', - name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), - truncateText: true, - }, - { - field: 'lastCompletedRun', - name: i18n.COLUMN_LAST_COMPLETE_RUN, - render: (value: TableData['lastCompletedRun']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); + history: H.History, + hasNoPermissions: boolean +): RulesColumns[] => { + const cols: RulesColumns[] = [ + { + field: 'rule', + name: i18n.COLUMN_RULE, + render: (value: TableData['rule']) => {value.name}, + truncateText: true, + width: '24%', }, - sortable: true, - truncateText: true, - width: '16%', - }, - { - field: 'lastResponse', - name: i18n.COLUMN_LAST_RESPONSE, - render: (value: TableData['lastResponse']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - <> - {value.type === 'Fail' ? ( - - {value.type} - - ) : ( - {value.type} - )} - - ); + { + field: 'method', + name: i18n.COLUMN_METHOD, + truncateText: true, }, - truncateText: true, - }, - { - field: 'tags', - name: i18n.COLUMN_TAGS, - render: (value: TableData['tags']) => ( -
- <> - {value.map((tag, i) => ( - - {tag} - - ))} - -
- ), - truncateText: true, - width: '20%', - }, - { - align: 'center', - field: 'activate', - name: i18n.COLUMN_ACTIVATE, - render: (value: TableData['activate'], item: TableData) => ( - - ), - sortable: true, - width: '85px', - }, - { - actions: getActions(dispatch, history), - width: '40px', - } as EuiTableActionsColumnType, -]; + { + field: 'severity', + name: i18n.COLUMN_SEVERITY, + render: (value: TableData['severity']) => ( + + {value} + + ), + truncateText: true, + }, + { + field: 'lastCompletedRun', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: TableData['lastCompletedRun']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: true, + truncateText: true, + width: '16%', + }, + { + field: 'lastResponse', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: TableData['lastResponse']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + <> + {value.type === 'Fail' ? ( + + {value.type} + + ) : ( + {value.type} + )} + + ); + }, + truncateText: true, + }, + { + field: 'tags', + name: i18n.COLUMN_TAGS, + render: (value: TableData['tags']) => ( +
+ <> + {value.map((tag, i) => ( + + {tag} + + ))} + +
+ ), + truncateText: true, + width: '20%', + }, + { + align: 'center', + field: 'activate', + name: i18n.COLUMN_ACTIVATE, + render: (value: TableData['activate'], item: TableData) => ( + + ), + sortable: true, + width: '85px', + }, + ]; + const actions: RulesColumns[] = [ + { + actions: getActions(dispatch, history), + width: '40px', + } as EuiTableActionsColumnType, + ]; + + return hasNoPermissions ? cols : [...cols, ...actions]; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 060f8baccc3b7..e900058b6c53c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -11,7 +11,7 @@ import { EuiLoadingContent, EuiSpacer, } from '@elastic/eui'; -import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { useHistory } from 'react-router-dom'; import uuid from 'uuid'; @@ -60,7 +60,11 @@ const initialState: State = { * * Delete * * Import/Export */ -export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importCompleteToggle => { +export const AllRules = React.memo<{ + hasNoPermissions: boolean; + importCompleteToggle: boolean; + loading: boolean; +}>(({ hasNoPermissions, importCompleteToggle, loading }) => { const [ { exportPayload, @@ -111,6 +115,15 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp }); }, [rulesData]); + const euiBasicTableSelectionProps = useMemo( + () => ({ + selectable: (item: TableData) => !item.isLoading, + onSelectionChange: (selected: TableData[]) => + dispatch({ type: 'setSelected', selectedItems: selected }), + }), + [] + ); + return ( <> (importComp {i18n.SELECTED_RULES(selectedItems.length)} - - {i18n.BATCH_ACTIONS} - + {!hasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} (importComp { @@ -204,14 +219,12 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp totalItemCount: pagination.total, pageSizeOptions: [5, 10, 20], }} - selection={{ - selectable: (item: TableData) => !item.isLoading, - onSelectionChange: (selected: TableData[]) => - dispatch({ type: 'setSelected', selectedItems: selected }), - }} sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }} + selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} /> - {isLoading && } + {(isLoading || loading) && ( + + )} )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index 725c7eeeedcfe..b3cc81b5cdfcf 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 @@ -45,6 +45,7 @@ export const AddItem = ({ isDisabled, validate, }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1); @@ -53,7 +54,8 @@ export const AddItem = ({ const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + field.setValue(newValues.length === 0 ? [''] : newValues); inputsRef.current = [ ...inputsRef.current.slice(0, index), ...inputsRef.current.slice(index + 1), @@ -70,11 +72,7 @@ export const AddItem = ({ const addItem = useCallback(() => { const values = field.value as string[]; - if (!isEmpty(values) && values[values.length - 1]) { - field.setValue([...values, '']); - } else if (isEmpty(values)) { - field.setValue(['']); - } + field.setValue([...values, '']); }, [field]); const updateItem = useCallback( @@ -82,22 +80,7 @@ export const AddItem = ({ event.persist(); const values = field.value as string[]; const value = event.target.value; - if (isEmpty(value)) { - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); - inputsRef.current = [ - ...inputsRef.current.slice(0, index), - ...inputsRef.current.slice(index + 1), - ]; - setHaveBeenKeyboardDeleted(inputsRef.current.length - 1); - inputsRef.current = inputsRef.current.map((ref, i) => { - if (i >= index && inputsRef.current[index] != null) { - ref.value = 're-render'; - } - return ref; - }); - } else { - field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); - } + field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); }, [field] ); @@ -131,8 +114,8 @@ export const AddItem = ({ - updateItem(e, index)} fullWidth {...euiFieldProps} /> + setShowValidation(true)} + onChange={e => updateItem(e, index)} + fullWidth + {...euiFieldProps} + /> removeItem(index)} aria-label={RuleI18n.DELETE} /> 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 198756fc2336b..af4f93c0fdbcd 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 @@ -143,6 +143,14 @@ const getDescriptionItem = ( description: timeline.title ?? DEFAULT_TIMELINE_TITLE, }, ]; + } else if (field === 'riskScore') { + const description: string = get(field, value); + return [ + { + title: label, + description, + }, + ]; } const description: string = get(field, value); if (!isEmpty(description)) { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx index 0995e0e916652..9695fd21067ee 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx @@ -11,7 +11,7 @@ export const FILTERS_LABEL = i18n.translate('xpack.siem.detectionEngine.createRu }); export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.QueryLabel', { - defaultMessage: 'Query', + defaultMessage: 'Custom query', }); export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index 97c4c2fdd050a..2c19e99e90114 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 @@ -16,7 +16,7 @@ import { EuiText, } from '@elastic/eui'; import { isEmpty, kebabCase, camelCase } from 'lodash/fp'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; @@ -41,6 +41,7 @@ interface AddItemProps { } export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const removeItem = useCallback( @@ -137,15 +138,16 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI t.tactics.includes(kebabCase(item.tactic.name)))} selectedOptions={item.techniques} onChange={updateTechniques.bind(null, index)} - isDisabled={disabled} + isDisabled={disabled || item.tactic.name === 'none'} fullWidth={true} - isInvalid={invalid} + isInvalid={showValidation && invalid} + onBlur={() => setShowValidation(true)} /> - {invalid && ( + {showValidation && invalid && (

{errorMessage}

@@ -155,7 +157,7 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI removeItem(index)} aria-label={Rulei18n.DELETE} /> @@ -186,7 +188,7 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI {index === 0 ? ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts index dd4c55c1503ec..557e91691b6c7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts @@ -18,7 +18,7 @@ export const TECHNIQUE = i18n.translate( ); export const ADD_MITRE_ATTACK = i18n.translate('xpack.siem.detectionEngine.mitreAttack.addTitle', { - defaultMessage: 'Add MITRE ATT&CK threat', + defaultMessage: 'Add MITRE ATT&CK\\u2122 threat', }); export const TECHNIQUES_PLACEHOLDER = i18n.translate( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx new file mode 100644 index 0000000000000..6ec76bacc2323 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +const ReadOnlyCallOutComponent = () => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + +

{i18n.READ_ONLY_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const ReadOnlyCallOut = memo(ReadOnlyCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts new file mode 100644 index 0000000000000..c3429f4365031 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const READ_ONLY_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.readOnlyCallOutTitle', + { + defaultMessage: 'Rule permissions required', + } +); + +export const READ_ONLY_CALLOUT_MSG = i18n.translate( + 'xpack.siem.detectionEngine.readOnlyCallOutMsg', + { + defaultMessage: + 'You are currently missing the required permissions to create/edit detection engine rule. Please contact your administrator for further assistance.', + } +); + +export const DISMISS_CALLOUT = i18n.translate('xpack.siem.detectionEngine.dismissButton', { + defaultMessage: 'Dismiss', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap index f264dde07c594..604f86866d565 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap @@ -11,7 +11,6 @@ exports[`RuleSwitch renders correctly against snapshot 1`] = ` ; id: string; enabled: boolean; + isDisabled?: boolean; isLoading?: boolean; optionLabel?: string; } @@ -42,6 +43,7 @@ export interface RuleSwitchProps { export const RuleSwitchComponent = ({ dispatch, id, + isDisabled, isLoading, enabled, optionLabel, @@ -92,7 +94,7 @@ export const RuleSwitchComponent = ({ data-test-subj="rule-switch" label={optionLabel ?? ''} showLabel={!isEmpty(optionLabel)} - disabled={false} + disabled={isDisabled} checked={myEnabled} onChange={onRuleStateChange} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 9355f1c8bfefa..008a1b48610d6 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 @@ -136,7 +136,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives', + defaultMessage: 'False positives examples', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, @@ -145,7 +145,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldMitreThreatLabel', { - defaultMessage: 'MITRE ATT&CK', + defaultMessage: 'MITRE ATT&CK\\u2122', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index 052986480e9ab..9323769765739 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; export const ADD_REFERENCE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addReferenceDescription', { - defaultMessage: 'Add reference', + defaultMessage: 'Add reference URL', } ); export const ADD_FALSE_POSITIVE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addFalsePositiveDescription', { - defaultMessage: 'Add false positive', + defaultMessage: 'Add false positive example', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index dbd7e3b3f96aa..079ec0dab4c5a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -38,7 +38,7 @@ export const schema: FormSchema = { i18n.translate( 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', { - defaultMessage: 'Index patterns for signals is required.', + defaultMessage: 'A minimum of one index pattern is required.', } ) ), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 12bbdbdfff3e9..ce91e15cdcf0d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -85,8 +85,12 @@ const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, - timeline_id: timeline.id, - timeline_title: timeline.title, + ...(timeline.id != null && timeline.title != null + ? { + timeline_id: timeline.id, + timeline_title: timeline.title, + } + : {}), threats: threats .filter(threat => threat.tactic.name !== 'none') .map(threat => ({ 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 848b17aadbff4..9a0f41bbd8c51 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 @@ -14,6 +14,7 @@ import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redir import { WrapperPage } from '../../../../components/wrapper_page'; import { usePersistRule } from '../../../../containers/detection_engine/rules'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useUserInfo } from '../../components/user_info'; import { AccordionTitle } from '../components/accordion_title'; import { FormData, FormHook } from '../components/shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; @@ -56,6 +57,13 @@ const MyEuiPanel = styled(EuiPanel)` `; export const CreateRuleComponent = React.memo(() => { + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); const [heightAccordion, setHeightAccordion] = useState(-1); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); @@ -77,6 +85,18 @@ export const CreateRuleComponent = React.memo(() => { [RuleStep.scheduleRule]: false, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } else if (userHasNoPermissions) { + return ; + } const setStepData = useCallback( (step: RuleStep, data: unknown, isValid: boolean) => { @@ -216,7 +236,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -242,7 +262,7 @@ export const CreateRuleComponent = React.memo(() => { setHeightAccordion(height)} @@ -273,7 +293,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -303,7 +323,7 @@ export const CreateRuleComponent = React.memo(() => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 9b6998ab4a132..679f42f025196 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -7,7 +7,7 @@ import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { memo, useCallback, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import { Redirect, useParams } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; import { ActionCreator } from 'typescript-fsa'; @@ -28,13 +28,16 @@ import { SpyRoute } from '../../../../utils/route/spy_routes'; import { SignalsHistogramPanel } from '../../components/signals_histogram_panel'; import { SignalsTable } from '../../components/signals'; +import { useUserInfo } from '../../components/user_info'; import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page'; import { useSignalInfo } from '../../components/signals_info'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; import { buildSignalsRuleIdFilter } from '../../components/signals/default_config'; +import { NoWriteSignalsCallOut } from '../../components/no_write_signals_callout'; import * as detectionI18n from '../../translations'; +import { ReadOnlyCallOut } from '../components/read_only_callout'; import { RuleSwitch } from '../components/rule_switch'; import { StepPanel } from '../components/step_panel'; import { getStepsData } from '../helpers'; @@ -50,10 +53,6 @@ import { State } from '../../../../store'; import { InputsRange } from '../../../../store/inputs/model'; import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; -interface OwnProps { - signalsIndex: string | null; -} - interface ReduxProps { filters: esFilters.Filter[]; query: Query; @@ -67,22 +66,41 @@ export interface DispatchProps { }>; } -type RuleDetailsComponentProps = OwnProps & ReduxProps & DispatchProps; +type RuleDetailsComponentProps = ReduxProps & DispatchProps; const RuleDetailsComponent = memo( - ({ filters, query, setAbsoluteRangeDatePicker, signalsIndex }) => { + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + hasIndexWrite, + signalIndexName, + } = useUserInfo(); const { ruleId } = useParams(); - const [loading, rule] = useRule(ruleId); + const [isLoading, rule] = useRule(ruleId); const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule, detailsView: true, }); const [lastSignals] = useSignalInfo({ ruleId }); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } - const title = loading === true || rule === null ? : rule.name; + const title = isLoading === true || rule === null ? : rule.name; const subTitle = useMemo( () => - loading === true || rule === null ? ( + isLoading === true || rule === null ? ( ) : ( [ @@ -118,7 +136,7 @@ const RuleDetailsComponent = memo( ), ] ), - [loading, rule] + [isLoading, rule] ); const signalDefaultFilters = useMemo( @@ -140,6 +158,8 @@ const RuleDetailsComponent = memo( return ( <> + {hasIndexWrite != null && !hasIndexWrite && } + {userHasNoPermissions && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -175,6 +195,7 @@ const RuleDetailsComponent = memo( @@ -186,7 +207,7 @@ const RuleDetailsComponent = memo( {ruleI18n.EDIT_RULE_SETTINGS} @@ -200,7 +221,7 @@ const RuleDetailsComponent = memo( - + {defineRuleData != null && ( ( - + {aboutRuleData != null && ( ( - + {scheduleRuleData != null && ( ( {ruleId != null && ( )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 10b7f0e832f19..e583461f52439 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -22,6 +22,7 @@ import { WrapperPage } from '../../../../components/wrapper_page'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useUserInfo } from '../../components/user_info'; import { FormHook, FormData } from '../components/shared_imports'; import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; @@ -47,9 +48,28 @@ interface ScheduleStepRuleForm extends StepRuleForm { } export const EditRuleComponent = memo(() => { + const { + loading: initLoading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); const { ruleId } = useParams(); const [loading, rule] = useRule(ruleId); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } else if (userHasNoPermissions) { + return ; + } + const [initForm, setInitForm] = useState(false); const [myAboutRuleForm, setMyAboutRuleForm] = useState({ data: null, @@ -89,7 +109,7 @@ export const EditRuleComponent = memo(() => { content: ( <> - + {myDefineRuleForm.data != null && ( { content: ( <> - + {myAboutRuleForm.data != null && ( { content: ( <> - + {myScheduleRuleForm.data != null && ( { ], [ loading, + initLoading, isLoading, myAboutRuleForm, myDefineRuleForm, @@ -310,7 +331,13 @@ export const EditRuleComponent = memo(() => { - + {i18n.SAVE_CHANGES} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 47b5c1051bcfc..cc0882dd7e426 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -5,6 +5,7 @@ */ import { pick } from 'lodash/fp'; +import { useLocation } from 'react-router-dom'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; @@ -64,3 +65,5 @@ export const getStepsData = ({ return { aboutRuleData, defineRuleData, scheduleRuleData }; }; + +export const useQuery = () => new URLSearchParams(useLocation().search); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index ef67f0a7d22c6..dd46b33ca7257 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -5,8 +5,9 @@ */ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useState } from 'react'; +import { Redirect } from 'react-router-dom'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine'; import { FormattedRelativePreferenceDate } from '../../../components/formatted_date'; @@ -17,15 +18,34 @@ import { SpyRoute } from '../../../utils/route/spy_routes'; import { AllRules } from './all'; import { ImportRuleModal } from './components/import_rule_modal'; +import { ReadOnlyCallOut } from './components/read_only_callout'; +import { useUserInfo } from '../components/user_info'; import * as i18n from './translations'; export const RulesComponent = React.memo(() => { const [showImportModal, setShowImportModal] = useState(false); const [importCompleteToggle, setImportCompleteToggle] = useState(false); + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; const lastCompletedRun = undefined; return ( <> + {userHasNoPermissions && } setShowImportModal(false)} @@ -56,6 +76,7 @@ export const RulesComponent = React.memo(() => { { setShowImportModal(true); }} @@ -63,20 +84,23 @@ export const RulesComponent = React.memo(() => { {i18n.IMPORT_RULE} - {i18n.ADD_NEW_RULE} - - +
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index ecd6bef942bfb..8d4407b9f73e8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -207,15 +207,15 @@ export const COLUMN_ACTIVATE = i18n.translate( ); export const DEFINE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.defineRuleTitle', { - defaultMessage: 'Define Rule', + defaultMessage: 'Define rule', }); export const ABOUT_RULE = i18n.translate('xpack.siem.detectionEngine.rules.aboutRuleTitle', { - defaultMessage: 'About Rule', + defaultMessage: 'About rule', }); export const SCHEDULE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.scheduleRuleTitle', { - defaultMessage: 'Schedule Rule', + defaultMessage: 'Schedule rule', }); export const DEFINITION = i18n.translate('xpack.siem.detectionEngine.rules.stepDefinitionTitle', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index ec4206623bad9..541b058951be7 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 @@ -109,8 +109,8 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; - timeline_id: string | null; - timeline_title: string | null; + timeline_id?: string; + timeline_title?: string; threats: IMitreEnterpriseAttack[]; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts new file mode 100644 index 0000000000000..cb358c15e5fad --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getIndexExists } from './get_index_exists'; + +class StatusCode extends Error { + status: number = -1; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +describe('get_index_exists', () => { + test('it should return a true if you have _shards', async () => { + const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 1 } }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(true); + }); + + test('it should return a false if you do NOT have _shards', async () => { + const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 0 } }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(false); + }); + + test('it should return a false if it encounters a 404', async () => { + const callWithRequest = jest.fn().mockImplementation(() => { + throw new StatusCode(404, 'I am a 404 error'); + }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(false); + }); + + test('it should reject if it encounters a non 404', async () => { + const callWithRequest = jest.fn().mockImplementation(() => { + throw new StatusCode(500, 'I am a 500 error'); + }); + await expect(getIndexExists(callWithRequest, 'some-index')).rejects.toThrow('I am a 500 error'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts index ff65caa59a866..705f542b50124 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts @@ -4,15 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IndicesExistsParams } from 'elasticsearch'; -import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch'; import { CallWithRequest } from '../types'; export const getIndexExists = async ( - callWithRequest: CallWithRequest, + callWithRequest: CallWithRequest< + { index: string; size: number; terminate_after: number; allow_no_indices: boolean }, + {}, + { _shards: { total: number } } + >, index: string ): Promise => { - return callWithRequest('indices.exists', { - index, - }); + try { + const response = await callWithRequest('search', { + index, + size: 0, + terminate_after: 1, + allow_no_indices: true, + }); + return response._shards.total > 0; + } catch (err) { + if (err.status === 404) { + return false; + } else { + throw err; + } + } }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 094449a5f61ac..10dc14f7ed610 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -28,7 +28,9 @@ describe('create_rules', () => { jest.resetAllMocks(); ({ server, alertsClient, actionsClient, elasticsearch } = createMockServer()); elasticsearch.getCluster = jest.fn().mockImplementation(() => ({ - callWithRequest: jest.fn().mockImplementation(() => true), + callWithRequest: jest + .fn() + .mockImplementation((endpoint, params) => ({ _shards: { total: 1 } })), })); createRulesRoute(server); diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 8a47aa2a27082..90ae79ef19d5b 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -60,7 +60,7 @@ export class Plugin { ], read: ['config'], }, - ui: ['show'], + ui: ['show', 'crud'], }, read: { api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts index 79a4eeb6dc48b..777471e209adc 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts @@ -99,7 +99,7 @@ export const setup = async (): Promise => { const tabs = ['snapshots', 'repositories']; testBed - .find('tab') + .find(`${tab}_tab`) .at(tabs.indexOf(tab)) .simulate('click'); }; @@ -360,7 +360,10 @@ export type TestSubjects = | 'state' | 'state.title' | 'state.value' - | 'tab' + | 'repositories_tab' + | 'snapshots_tab' + | 'policies_tab' + | 'restore_status_tab' | 'tableHeaderCell_durationInMillis_3' | 'tableHeaderCell_durationInMillis_3.tableHeaderSortButton' | 'tableHeaderCell_indices_4' diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index d9f2c1b510a14..cb2e94df75609 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -95,6 +95,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setCleanupRepositoryResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 503 : 200; + + server.respondWith('POST', `${API_BASE_PATH}repositories/:name/cleanup`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + const setGetPolicyResponse = (response?: HttpResponse) => { server.respondWith('GET', `${API_BASE_PATH}policy/:name`, [ 200, @@ -113,6 +123,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setLoadIndicesResponse, setAddPolicyResponse, setGetPolicyResponse, + setCleanupRepositoryResponse, }; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index aa659441043ae..517c7a0059a7e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -88,8 +88,15 @@ describe('', () => { test('should have 4 tabs', () => { const { find } = testBed; - expect(find('tab').length).toBe(4); - expect(find('tab').map(t => t.text())).toEqual([ + const tabs = [ + find('snapshots_tab'), + find('repositories_tab'), + find('policies_tab'), + find('restore_status_tab'), + ]; + + expect(tabs.length).toBe(4); + expect(tabs.map(t => t.text())).toEqual([ 'Snapshots', 'Repositories', 'Policies', diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts index 5900d53afa0b4..b9b26c5590324 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts @@ -157,3 +157,15 @@ export interface InvalidRepositoryVerification { } export type RepositoryVerification = ValidRepositoryVerification | InvalidRepositoryVerification; + +export interface SuccessfulRepositoryCleanup { + cleaned: true; + response: object; +} + +export interface FailedRepositoryCleanup { + cleaned: false; + error: object; +} + +export type RepositoryCleanup = FailedRepositoryCleanup | SuccessfulRepositoryCleanup; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index 844394deb4f8d..481516479df4e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -103,6 +103,7 @@ export const UIM_REPOSITORY_DELETE = 'repository_delete'; export const UIM_REPOSITORY_DELETE_MANY = 'repository_delete_many'; export const UIM_REPOSITORY_SHOW_DETAILS_CLICK = 'repository_show_details_click'; export const UIM_REPOSITORY_DETAIL_PANEL_VERIFY = 'repository_detail_panel_verify'; +export const UIM_REPOSITORY_DETAIL_PANEL_CLEANUP = 'repository_detail_panel_cleanup'; export const UIM_SNAPSHOT_LIST_LOAD = 'snapshot_list_load'; export const UIM_SNAPSHOT_SHOW_DETAILS_CLICK = 'snapshot_show_details_click'; export const UIM_SNAPSHOT_DETAIL_PANEL_SUMMARY_TAB = 'snapshot_detail_panel_summary_tab'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx index 35d5c0b610b3c..f89aa869b3366 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx @@ -150,7 +150,7 @@ export const SnapshotRestoreHome: React.FunctionComponent onSectionChange(tab.id)} isSelected={tab.id === section} key={tab.id} - data-test-subj="tab" + data-test-subj={tab.id.toLowerCase() + '_tab'} > {tab.name} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx index c03162bae8bd2..0a3fcfc2ec6e7 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -19,6 +18,8 @@ import { EuiLink, EuiSpacer, EuiTitle, + EuiCodeBlock, + EuiText, } from '@elastic/eui'; import 'brace/theme/textmate'; @@ -28,12 +29,17 @@ import { documentationLinksService } from '../../../../services/documentation'; import { useLoadRepository, verifyRepository as verifyRepositoryRequest, + cleanupRepository as cleanupRepositoryRequest, } from '../../../../services/http'; import { textService } from '../../../../services/text'; import { linkToSnapshots, linkToEditRepository } from '../../../../services/navigation'; import { REPOSITORY_TYPES } from '../../../../../../common/constants'; -import { Repository, RepositoryVerification } from '../../../../../../common/types'; +import { + Repository, + RepositoryVerification, + RepositoryCleanup, +} from '../../../../../../common/types'; import { RepositoryDeleteProvider, SectionError, @@ -61,7 +67,9 @@ export const RepositoryDetails: React.FunctionComponent = ({ const { FormattedMessage } = i18n; const { error, data: repositoryDetails } = useLoadRepository(repositoryName); const [verification, setVerification] = useState(undefined); + const [cleanup, setCleanup] = useState(undefined); const [isLoadingVerification, setIsLoadingVerification] = useState(false); + const [isLoadingCleanup, setIsLoadingCleanup] = useState(false); const verifyRepository = async () => { setIsLoadingVerification(true); @@ -70,11 +78,20 @@ export const RepositoryDetails: React.FunctionComponent = ({ setIsLoadingVerification(false); }; - // Reset verification state when repository name changes, either from adjust URL or clicking + const cleanupRepository = async () => { + setIsLoadingCleanup(true); + const { data } = await cleanupRepositoryRequest(repositoryName); + setCleanup(data.cleanup); + setIsLoadingCleanup(false); + }; + + // Reset verification state and cleanup when repository name changes, either from adjust URL or clicking // into a different repository in table list. useEffect(() => { setVerification(undefined); setIsLoadingVerification(false); + setCleanup(undefined); + setIsLoadingCleanup(false); }, [repositoryName]); const renderBody = () => { @@ -231,6 +248,8 @@ export const RepositoryDetails: React.FunctionComponent = ({ {renderVerification()} + + {renderCleanup()} ); }; @@ -260,36 +279,13 @@ export const RepositoryDetails: React.FunctionComponent = ({ {verification ? ( - + {JSON.stringify( verification.valid ? verification.response : verification.error, null, 2 )} - setOptions={{ - showLineNumbers: false, - tabSize: 2, - maxLines: Infinity, - }} - editorProps={{ - $blockScrolling: Infinity, - }} - showGutter={false} - minLines={6} - aria-label={ - - } - /> + ) : null} @@ -318,6 +314,78 @@ export const RepositoryDetails: React.FunctionComponent = ({ ); + const renderCleanup = () => ( + <> + +

+ +

+
+ + +

+ +

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

+ +

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

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

+
+ )} + + ) : null} + + + + + + ); + const renderFooter = () => { return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx index 4b5270b44d593..1df06f67c35b1 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx @@ -96,6 +96,7 @@ export const RepositoryTable: React.FunctionComponent = ({ }, }, { + field: 'actions', name: i18n.translate('xpack.snapshotRestore.repositoryList.table.actionsColumnTitle', { defaultMessage: 'Actions', }), @@ -302,8 +303,8 @@ export const RepositoryTable: React.FunctionComponent = ({ rowProps={() => ({ 'data-test-subj': 'row', })} - cellProps={() => ({ - 'data-test-subj': 'cell', + cellProps={(item, field) => ({ + 'data-test-subj': `${field.name}_cell`, })} data-test-subj="repositoryTable" /> diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts index 171e949ccee75..b92f21ea6a9b6 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts @@ -11,6 +11,7 @@ import { UIM_REPOSITORY_DELETE, UIM_REPOSITORY_DELETE_MANY, UIM_REPOSITORY_DETAIL_PANEL_VERIFY, + UIM_REPOSITORY_DETAIL_PANEL_CLEANUP, } from '../../constants'; import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; @@ -44,6 +45,20 @@ export const verifyRepository = async (name: Repository['name']) => { return result; }; +export const cleanupRepository = async (name: Repository['name']) => { + const result = await sendRequest({ + path: httpService.addBasePath( + `${API_BASE_PATH}repositories/${encodeURIComponent(name)}/cleanup` + ), + method: 'post', + body: undefined, + }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_REPOSITORY_DETAIL_PANEL_CLEANUP); + return result; +}; + export const useLoadRepositoryTypes = () => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}repository_types`), diff --git a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts similarity index 73% rename from x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts rename to x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts index 82fe30aaa7d2e..794bf99c3d918 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts @@ -7,10 +7,10 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) => { const ca = components.clientAction.factory; - Client.prototype.slm = components.clientAction.namespaceFactory(); - const slm = Client.prototype.slm.prototype; + Client.prototype.sr = components.clientAction.namespaceFactory(); + const sr = Client.prototype.sr.prototype; - slm.policies = ca({ + sr.policies = ca({ urls: [ { fmt: '/_slm/policy', @@ -19,7 +19,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); - slm.policy = ca({ + sr.policy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -33,7 +33,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); - slm.deletePolicy = ca({ + sr.deletePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -47,7 +47,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'DELETE', }); - slm.executePolicy = ca({ + sr.executePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>/_execute', @@ -61,7 +61,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); - slm.updatePolicy = ca({ + sr.updatePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -75,7 +75,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); - slm.executeRetention = ca({ + sr.executeRetention = ca({ urls: [ { fmt: '/_slm/_execute_retention', @@ -83,4 +83,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'POST', }); + + sr.cleanupRepository = ca({ + urls: [ + { + fmt: '/_snapshot/<%=name%>/_cleanup', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'POST', + }); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index bbfc82b8a6de9..9f434ac10c16a 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -40,7 +40,7 @@ export const getAllHandler: RouterRouteHandler = async ( // Get policies const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policies', { + } = await callWithRequest('sr.policies', { human: true, }); @@ -62,7 +62,7 @@ export const getOneHandler: RouterRouteHandler = async ( const { name } = req.params; const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policy', { + } = await callWithRequest('sr.policy', { name, human: true, }); @@ -82,7 +82,7 @@ export const getOneHandler: RouterRouteHandler = async ( export const executeHandler: RouterRouteHandler = async (req, callWithRequest) => { const { name } = req.params; - const { snapshot_name: snapshotName } = await callWithRequest('slm.executePolicy', { + const { snapshot_name: snapshotName } = await callWithRequest('sr.executePolicy', { name, }); return { snapshotName }; @@ -98,7 +98,7 @@ export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => await Promise.all( policyNames.map(name => { - return callWithRequest('slm.deletePolicy', { name }) + return callWithRequest('sr.deletePolicy', { name }) .then(() => response.itemsDeleted.push(name)) .catch(e => response.errors.push({ @@ -122,7 +122,7 @@ export const createHandler: RouterRouteHandler = async (req, callWithRequest) => // Check that policy with the same name doesn't already exist try { - const policyByName = await callWithRequest('slm.policy', { name }); + const policyByName = await callWithRequest('sr.policy', { name }); if (policyByName[name]) { throw conflictError; } @@ -134,7 +134,7 @@ export const createHandler: RouterRouteHandler = async (req, callWithRequest) => } // Otherwise create new policy - return await callWithRequest('slm.updatePolicy', { + return await callWithRequest('sr.updatePolicy', { name, body: serializePolicy(policy), }); @@ -146,10 +146,10 @@ export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => // Check that policy with the given name exists // If it doesn't exist, 404 will be thrown by ES and will be returned - await callWithRequest('slm.policy', { name }); + await callWithRequest('sr.policy', { name }); // Otherwise update policy - return await callWithRequest('slm.updatePolicy', { + return await callWithRequest('sr.updatePolicy', { name, body: serializePolicy(policy), }); @@ -210,5 +210,5 @@ export const updateRetentionSettingsHandler: RouterRouteHandler = async (req, ca }; export const executeRetentionHandler: RouterRouteHandler = async (_req, callWithRequest) => { - return await callWithRequest('slm.executeRetention'); + return await callWithRequest('sr.executeRetention'); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts index f6ac946ab07d5..3d67494da4aad 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts @@ -15,6 +15,7 @@ import { RepositoryType, RepositoryVerification, SlmPolicyEs, + RepositoryCleanup, } from '../../../common/types'; import { Plugins } from '../../shim'; @@ -34,6 +35,7 @@ export function registerRepositoriesRoutes(router: Router, plugins: Plugins) { router.get('repositories', getAllHandler); router.get('repositories/{name}', getOneHandler); router.get('repositories/{name}/verify', getVerificationHandler); + router.post('repositories/{name}/cleanup', getCleanupHandler); router.put('repositories', createHandler); router.put('repositories/{name}', updateHandler); router.delete('repositories/{names}', deleteHandler); @@ -74,7 +76,7 @@ export const getAllHandler: RouterRouteHandler = async ( try { const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policies', { + } = await callWithRequest('sr.policies', { human: true, }); const managedRepositoryPolicy = Object.entries(policiesByName) @@ -172,6 +174,31 @@ export const getVerificationHandler: RouterRouteHandler = async ( }; }; +export const getCleanupHandler: RouterRouteHandler = async ( + req, + callWithRequest +): Promise<{ + cleanup: RepositoryCleanup | {}; +}> => { + const { name } = req.params; + + const cleanupResults = await callWithRequest('sr.cleanupRepository', { + name, + }).catch(e => ({ + cleaned: false, + error: e.response ? JSON.parse(e.response) : e, + })); + + return { + cleanup: cleanupResults.error + ? cleanupResults + : { + cleaned: true, + response: cleanupResults, + }, + }; +}; + export const getTypesHandler: RouterRouteHandler = async () => { // In ECE/ESS, do not enable the default types const types: RepositoryType[] = isCloudEnabled ? [] : [...DEFAULT_REPOSITORY_TYPES]; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts index 042a2dfeaf6b5..0d34d6a6b1b31 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -38,7 +38,7 @@ export const getAllHandler: RouterRouteHandler = async ( // Attempt to retrieve policies // This could fail if user doesn't have access to read SLM policies try { - const policiesByName = await callWithRequest('slm.policies'); + const policiesByName = await callWithRequest('sr.policies'); policies = Object.keys(policiesByName); } catch (e) { // Silently swallow error as policy names aren't required in UI diff --git a/x-pack/legacy/plugins/snapshot_restore/server/shim.ts b/x-pack/legacy/plugins/snapshot_restore/server/shim.ts index 84c9ddf8e0bea..d64f35c64f11e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/shim.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/shim.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { createRouter, Router } from '../../../server/lib/create_router'; import { registerLicenseChecker } from '../../../server/lib/register_license_checker'; -import { elasticsearchJsPlugin } from './client/elasticsearch_slm'; +import { elasticsearchJsPlugin } from './client/elasticsearch_sr'; import { CloudSetup } from '../../../../plugins/cloud/server'; export interface Core { http: { diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index 080ce34c09f3c..92e27d20323a7 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -30,8 +30,6 @@ export interface Query { /** Fetch the most recent event data for a monitor ID, date range, location. */ getLatestMonitors: Ping[]; - getFilterBar?: FilterBar | null; - /** Fetches the current state of Uptime monitors for the given parameters. */ getMonitorStates?: MonitorSummaryResult | null; /** Fetches details about the uptime index. */ @@ -467,21 +465,6 @@ export interface StatusData { /** The total down counts for this point. */ total?: number | null; } -/** The data used to enrich the filter bar. */ -export interface FilterBar { - /** A series of monitor IDs in the heartbeat indices. */ - ids?: string[] | null; - /** The location values users have configured for the agents. */ - locations?: string[] | null; - /** The ports of the monitored endpoints. */ - ports?: number[] | null; - /** The schemes used by the monitors. */ - schemes?: string[] | null; - /** The possible status values contained in the indices. */ - statuses?: string[] | null; - /** The list of URLs */ - urls?: string[] | null; -} /** The primary object returned for monitor states. */ export interface MonitorSummaryResult { diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts index c74bc81ae2549..58f79abcf91ec 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/index.ts @@ -5,5 +5,6 @@ */ export * from './common'; -export * from './snapshot'; export * from './monitor'; +export * from './overview_filters'; +export * from './snapshot'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts new file mode 100644 index 0000000000000..a803a0720959a --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { OverviewFiltersType, OverviewFilters } from './overview_filters'; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts new file mode 100644 index 0000000000000..9b9241494f001 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const OverviewFiltersType = t.type({ + locations: t.array(t.string), + ports: t.array(t.number), + schemes: t.array(t.string), + tags: t.array(t.string), +}); + +export type OverviewFilters = t.TypeOf; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap index 2022390d0e5d9..0e6ea3662b97e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap @@ -13,6 +13,7 @@ exports[`FilterPopover component does not show item list when loading 1`] = ` /> } closePopover={[Function]} + data-test-subj="filter-popover_test" display="inlineBlock" hasArrow={true} id="test" @@ -49,6 +50,7 @@ exports[`FilterPopover component renders without errors for valid props 1`] = ` /> } closePopover={[Function]} + data-test-subj="filter-popover_test" display="inlineBlock" hasArrow={true} id="test" @@ -83,6 +85,7 @@ exports[`FilterPopover component returns selected items on popover close 1`] = `
{ props = { fieldName: 'foo', id: 'test', - isLoading: false, + loading: false, items: ['first', 'second', 'third', 'fourth'], onFilterFieldChange: jest.fn(), selectedItems: ['first', 'third'], @@ -47,7 +47,7 @@ describe('FilterPopover component', () => { }); it('does not show item list when loading', () => { - props.isLoading = true; + props.loading = true; const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts new file mode 100644 index 0000000000000..8deee25377850 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/__tests__/parse_filter_map.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parseFiltersMap } from '../parse_filter_map'; + +describe('parseFiltersMap', () => { + it('provides values from valid filter string', () => { + expect( + parseFiltersMap( + '[["url.port",["5601","80"]],["observer.geo.name",["us-east-2"]],["monitor.type",["http","tcp"]]]' + ) + ).toMatchSnapshot(); + }); + + it('returns an empty object for invalid filter', () => { + expect(() => parseFiltersMap('some invalid string')).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx index f27514bf76a11..351302fb38356 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_group.tsx @@ -5,35 +5,49 @@ */ import { EuiFilterGroup } from '@elastic/eui'; -import React from 'react'; -import { get } from 'lodash'; +import React, { useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { FilterBar as FilterBarType } from '../../../../common/graphql/types'; -import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; -import { filterBarQuery } from '../../../queries'; +import { connect } from 'react-redux'; import { FilterPopoverProps, FilterPopover } from './filter_popover'; import { FilterStatusButton } from './filter_status_button'; +import { OverviewFilters } from '../../../../common/runtime_types'; +import { fetchOverviewFilters, GetOverviewFiltersPayload } from '../../../state/actions'; +import { AppState } from '../../../state'; +import { useUrlParams } from '../../../hooks'; +import { parseFiltersMap } from './parse_filter_map'; -interface FilterBarQueryResult { - filters?: FilterBarType; +interface OwnProps { + currentFilter: any; + onFilterUpdate: any; + dateRangeStart: string; + dateRangeEnd: string; + filters?: string; + statusFilter?: string; } -interface FilterBarDropdownsProps { - currentFilter: string; - onFilterUpdate: (kuery: string) => void; +interface StoreProps { + esKuery: string; + lastRefresh: number; + loading: boolean; + overviewFilters: OverviewFilters; } -type Props = UptimeGraphQLQueryProps & FilterBarDropdownsProps; +interface DispatchProps { + loadFilterGroup: typeof fetchOverviewFilters; +} + +type Props = OwnProps & StoreProps & DispatchProps; + +type PresentationalComponentProps = Pick & + Pick; -export const FilterGroupComponent = ({ - loading: isLoading, +export const PresentationalComponent: React.FC = ({ currentFilter, - data, + overviewFilters, + loading, onFilterUpdate, -}: Props) => { - const locations = get(data, 'filterBar.locations', []); - const ports = get(data, 'filterBar.ports', []); - const schemes = get(data, 'filterBar.schemes', []); +}) => { + const { locations, ports, schemes, tags } = overviewFilters; let filterKueries: Map; try { @@ -67,36 +81,50 @@ export const FilterGroupComponent = ({ const filterPopoverProps: FilterPopoverProps[] = [ { + loading, + onFilterFieldChange, fieldName: 'observer.geo.name', id: 'location', - isLoading, items: locations, - onFilterFieldChange, selectedItems: getSelectedItems('observer.geo.name'), title: i18n.translate('xpack.uptime.filterBar.options.location.name', { defaultMessage: 'Location', }), }, { + loading, + onFilterFieldChange, fieldName: 'url.port', id: 'port', - isLoading, - items: ports, - onFilterFieldChange, + disabled: ports.length === 0, + items: ports.map((p: number) => p.toString()), selectedItems: getSelectedItems('url.port'), title: i18n.translate('xpack.uptime.filterBar.options.portLabel', { defaultMessage: 'Port' }), }, { + loading, + onFilterFieldChange, fieldName: 'monitor.type', id: 'scheme', - isLoading, + disabled: schemes.length === 0, items: schemes, - onFilterFieldChange, selectedItems: getSelectedItems('monitor.type'), title: i18n.translate('xpack.uptime.filterBar.options.schemeLabel', { defaultMessage: 'Scheme', }), }, + { + loading, + onFilterFieldChange, + fieldName: 'tags', + id: 'tags', + disabled: tags.length === 0, + items: tags, + selectedItems: getSelectedItems('tags'), + title: i18n.translate('xpack.uptime.filterBar.options.tagsLabel', { + defaultMessage: 'Tags', + }), + }, ]; return ( @@ -124,7 +152,59 @@ export const FilterGroupComponent = ({ ); }; -export const FilterGroup = withUptimeGraphQL( - FilterGroupComponent, - filterBarQuery -); +export const Container: React.FC = ({ + currentFilter, + esKuery, + filters, + loading, + loadFilterGroup, + dateRangeStart, + dateRangeEnd, + overviewFilters, + statusFilter, + onFilterUpdate, +}: Props) => { + const [getUrlParams] = useUrlParams(); + const { filters: urlFilters } = getUrlParams(); + useEffect(() => { + const filterSelections = parseFiltersMap(urlFilters); + loadFilterGroup({ + dateRangeStart, + dateRangeEnd, + locations: filterSelections.locations ?? [], + ports: filterSelections.ports ?? [], + schemes: filterSelections.schemes ?? [], + search: esKuery, + statusFilter, + tags: filterSelections.tags ?? [], + }); + }, [dateRangeStart, dateRangeEnd, esKuery, filters, statusFilter, urlFilters, loadFilterGroup]); + return ( + + ); +}; + +const mapStateToProps = ({ + overviewFilters: { loading, filters }, + ui: { esKuery, lastRefresh }, +}: AppState): StoreProps => ({ + esKuery, + overviewFilters: filters, + lastRefresh, + loading, +}); + +const mapDispatchToProps = (dispatch: any): DispatchProps => ({ + loadFilterGroup: (payload: GetOverviewFiltersPayload) => dispatch(fetchOverviewFilters(payload)), +}); + +export const FilterGroup = connect( + // @ts-ignore connect is expecting null | undefined for some reason + mapStateToProps, + mapDispatchToProps +)(Container); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx index 6e73090782b04..f96fef609fe76 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_popover.tsx @@ -14,7 +14,8 @@ import { LocationLink } from '../monitor_list'; export interface FilterPopoverProps { fieldName: string; id: string; - isLoading: boolean; + loading: boolean; + disabled?: boolean; items: string[]; onFilterFieldChange: (fieldName: string, values: string[]) => void; selectedItems: string[]; @@ -27,7 +28,8 @@ const isItemSelected = (selectedItems: string[], item: string): 'on' | undefined export const FilterPopover = ({ fieldName, id, - isLoading, + disabled, + loading, items, onFilterFieldChange, selectedItems, @@ -48,10 +50,10 @@ export const FilterPopover = ({ }, [searchQuery, items]); return ( - // @ts-ignore zIndex prop is not described in the typing yet 0} numFilters={items.length} numActiveFilters={tempSelectedItems.length} @@ -66,6 +68,7 @@ export const FilterPopover = ({ setIsOpen(false); onFilterFieldChange(fieldName, tempSelectedItems); }} + data-test-subj={`filter-popover_${id}`} id={id} isOpen={isOpen} ownFocus={true} @@ -77,7 +80,7 @@ export const FilterPopover = ({ disabled={items.length === 0} onSearch={query => setSearchQuery(query)} placeholder={ - isLoading + loading ? i18n.translate('xpack.uptime.filterPopout.loadingMessage', { defaultMessage: 'Loading...', }) @@ -90,10 +93,11 @@ export const FilterPopover = ({ } /> - {!isLoading && + {!loading && itemsToDisplay.map(item => ( toggleSelectedItems(item, tempSelectedItems, setTempSelectedItems)} > diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx index 95f4c30337d62..abbe72530fd80 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/filter_status_button.tsx @@ -11,6 +11,7 @@ import { useUrlParams } from '../../../hooks'; export interface FilterStatusButtonProps { content: string; dataTestSubj: string; + isDisabled?: boolean; value: string; withNext: boolean; } @@ -18,6 +19,7 @@ export interface FilterStatusButtonProps { export const FilterStatusButton = ({ content, dataTestSubj, + isDisabled, value, withNext, }: FilterStatusButtonProps) => { @@ -27,6 +29,7 @@ export const FilterStatusButton = ({ { const nextFilter = { statusFilter: urlValue === value ? '' : value, pagination: '' }; setUrlParams(nextFilter); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts new file mode 100644 index 0000000000000..08766521799ea --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/parse_filter_map.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface FilterField { + name: string; + fieldName: string; +} + +/** + * These are the only filter fields we are looking to catch at the moment. + * If your code needs to support custom fields, introduce a second parameter to + * `parseFiltersMap` to take a list of FilterField objects. + */ +const filterWhitelist: FilterField[] = [ + { name: 'ports', fieldName: 'url.port' }, + { name: 'locations', fieldName: 'observer.geo.name' }, + { name: 'tags', fieldName: 'tags' }, + { name: 'schemes', fieldName: 'monitor.type' }, +]; + +export const parseFiltersMap = (filterMapString: string) => { + if (!filterMapString) { + return {}; + } + const filterSlices: { [key: string]: any } = {}; + try { + const map = new Map(JSON.parse(filterMapString)); + filterWhitelist.forEach(({ name, fieldName }) => { + filterSlices[name] = map.get(fieldName) ?? []; + }); + return filterSlices; + } catch { + throw new Error('Unable to parse invalid filter string'); + } +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx index fc0c6342bd6e9..0e05c17d57353 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/filter_group/uptime_filter_button.tsx @@ -8,6 +8,7 @@ import { EuiFilterButton } from '@elastic/eui'; import React from 'react'; interface UptimeFilterButtonProps { + isDisabled?: boolean; isSelected: boolean; numFilters: number; numActiveFilters: number; @@ -16,6 +17,7 @@ interface UptimeFilterButtonProps { } export const UptimeFilterButton = ({ + isDisabled, isSelected, numFilters, numActiveFilters, @@ -25,6 +27,7 @@ export const UptimeFilterButton = ({ { + let params: URLSearchParams; + + beforeEach(() => { + params = new URLSearchParams(); + }); + + it('parameterizes the provided values for a given field name', () => { + parameterizeValues(params, { foo: ['bar', 'baz'] }); + expect(params.toString()).toMatchSnapshot(); + }); + + it('parameterizes provided values for multiple fields', () => { + parameterizeValues(params, { foo: ['bar', 'baz'], bar: ['foo', 'baz'] }); + expect(params.toString()).toMatchSnapshot(); + }); + + it('returns an empty string when there are no values provided', () => { + parameterizeValues(params, { foo: [] }); + expect(params.toString()).toBe(''); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts index a4cfb1c51b0ec..ced06ce7a1d7b 100644 --- a/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/index.ts @@ -9,6 +9,7 @@ export { convertMicrosecondsToMilliseconds } from './convert_measurements'; export * from './observability_integration'; export { getApiPath } from './get_api_path'; export { getChartDateLabel } from './charts'; +export { parameterizeValues } from './parameterize_values'; export { seriesHasDownValues } from './series_has_down_values'; export { stringifyKueries } from './stringify_kueries'; export { toStaticIndexPattern } from './to_static_index_pattern'; diff --git a/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts new file mode 100644 index 0000000000000..4c9fa6838c2ed --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/lib/helper/parameterize_values.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const parameterizeValues = ( + params: URLSearchParams, + obj: Record +): void => { + Object.keys(obj).forEach(key => { + obj[key].forEach(val => { + params.append(key, val); + }); + }); +}; diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index edf06242053a2..e7ef7f53afde4 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -22,6 +22,8 @@ import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; import { useTrackPageview } from '../../../infra/public'; import { combineFiltersAndUserSearch, stringifyKueries, toStaticIndexPattern } from '../lib/helper'; import { AutocompleteProviderRegister, esKuery } from '../../../../../../src/plugins/data/public'; +import { store } from '../state'; +import { setEsKueryString } from '../state/actions'; import { PageHeader } from './page_header'; interface OverviewPageProps { @@ -64,7 +66,6 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { useTrackPageview({ app: 'uptime', path: 'overview' }); useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 }); - const filterQueryString = search || ''; let error: any; let kueryString: string = ''; try { @@ -76,6 +77,7 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { kueryString = ''; } + const filterQueryString = search || ''; let filters: any | undefined; try { if (filterQueryString || urlFilters) { @@ -85,6 +87,15 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { const ast = esKuery.fromKueryExpression(combinedFilterString); const elasticsearchQuery = esKuery.toElasticsearchQuery(ast, staticIndexPattern); filters = JSON.stringify(elasticsearchQuery); + const searchDSL: string = filterQueryString + ? JSON.stringify( + esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(filterQueryString), + staticIndexPattern + ) + ) + : ''; + store.dispatch(setEsKueryString(searchDSL)); } } } catch (e) { @@ -110,13 +121,13 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => { { if (urlFilters !== filtersKuery) { updateUrl({ filters: filtersKuery, pagination: '' }); } }} - variables={sharedProps} /> {error && } diff --git a/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts b/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts deleted file mode 100644 index a9b7e52c0f793..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/queries/filter_bar_query.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const filterBarQueryString = ` -query FilterBar($dateRangeStart: String!, $dateRangeEnd: String!) { - filterBar: getFilterBar(dateRangeStart: $dateRangeStart, dateRangeEnd: $dateRangeEnd) { - ids - locations - ports - schemes - urls - } -} -`; - -export const filterBarQuery = gql` - ${filterBarQueryString} -`; diff --git a/x-pack/legacy/plugins/uptime/public/queries/index.ts b/x-pack/legacy/plugins/uptime/public/queries/index.ts index f4947f5325450..02c9c7cb23403 100644 --- a/x-pack/legacy/plugins/uptime/public/queries/index.ts +++ b/x-pack/legacy/plugins/uptime/public/queries/index.ts @@ -5,6 +5,5 @@ */ export { docCountQuery, docCountQueryString } from './doc_count_query'; -export { filterBarQuery, filterBarQueryString } from './filter_bar_query'; export { monitorChartsQuery, monitorChartsQueryString } from './monitor_charts_query'; export { pingsQuery, pingsQueryString } from './pings_query'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap new file mode 100644 index 0000000000000..6fe2c8eaa362d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`overview filters action creators creates a fail action 1`] = ` +Object { + "payload": [Error: There was an error retrieving the overview filters], + "type": "FETCH_OVERVIEW_FILTERS_FAIL", +} +`; + +exports[`overview filters action creators creates a get action 1`] = ` +Object { + "payload": Object { + "dateRangeEnd": "now", + "dateRangeStart": "now-15m", + "locations": Array [ + "fairbanks", + "tokyo", + ], + "ports": Array [ + "80", + ], + "schemes": Array [ + "http", + "tcp", + ], + "search": "", + "statusFilter": "down", + "tags": Array [ + "api", + "dev", + ], + }, + "type": "FETCH_OVERVIEW_FILTERS", +} +`; + +exports[`overview filters action creators creates a success action 1`] = ` +Object { + "payload": Object { + "locations": Array [ + "fairbanks", + "tokyo", + "london", + ], + "ports": Array [ + 80, + 443, + ], + "schemes": Array [ + "http", + "tcp", + ], + "tags": Array [ + "api", + "dev", + "prod", + ], + }, + "type": "FETCH_OVERVIEW_FILTERS_SUCCESS", +} +`; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts new file mode 100644 index 0000000000000..4765e1327ce31 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/__tests__/overview_filters.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + fetchOverviewFilters, + fetchOverviewFiltersSuccess, + fetchOverviewFiltersFail, +} from '../overview_filters'; + +describe('overview filters action creators', () => { + it('creates a get action', () => { + expect( + fetchOverviewFilters({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + statusFilter: 'down', + search: '', + locations: ['fairbanks', 'tokyo'], + ports: ['80'], + schemes: ['http', 'tcp'], + tags: ['api', 'dev'], + }) + ).toMatchSnapshot(); + }); + + it('creates a success action', () => { + expect( + fetchOverviewFiltersSuccess({ + locations: ['fairbanks', 'tokyo', 'london'], + ports: [80, 443], + schemes: ['http', 'tcp'], + tags: ['api', 'dev', 'prod'], + }) + ).toMatchSnapshot(); + }); + + it('creates a fail action', () => { + expect( + fetchOverviewFiltersFail(new Error('There was an error retrieving the overview filters')) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts index 3174c0023ed92..9874da1839c2f 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './overview_filters'; export * from './snapshot'; export * from './ui'; export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts new file mode 100644 index 0000000000000..dbbd01e34b4d4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/actions/overview_filters.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverviewFilters } from '../../../common/runtime_types'; + +export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS'; +export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL'; +export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS'; + +export interface GetOverviewFiltersPayload { + dateRangeStart: string; + dateRangeEnd: string; + locations: string[]; + ports: string[]; + schemes: string[]; + search?: string; + statusFilter?: string; + tags: string[]; +} + +interface GetOverviewFiltersFetchAction { + type: typeof FETCH_OVERVIEW_FILTERS; + payload: GetOverviewFiltersPayload; +} + +interface GetOverviewFiltersSuccessAction { + type: typeof FETCH_OVERVIEW_FILTERS_SUCCESS; + payload: OverviewFilters; +} + +interface GetOverviewFiltersFailAction { + type: typeof FETCH_OVERVIEW_FILTERS_FAIL; + payload: Error; +} + +export type OverviewFiltersAction = + | GetOverviewFiltersFetchAction + | GetOverviewFiltersSuccessAction + | GetOverviewFiltersFailAction; + +export const fetchOverviewFilters = ( + payload: GetOverviewFiltersPayload +): GetOverviewFiltersFetchAction => ({ + type: FETCH_OVERVIEW_FILTERS, + payload, +}); + +export const fetchOverviewFiltersFail = (error: Error): GetOverviewFiltersFailAction => ({ + type: FETCH_OVERVIEW_FILTERS_FAIL, + payload: error, +}); + +export const fetchOverviewFiltersSuccess = ( + filters: OverviewFilters +): GetOverviewFiltersSuccessAction => ({ + type: FETCH_OVERVIEW_FILTERS_SUCCESS, + payload: filters, +}); diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts index fe87a6a5960ee..57d2b4ce38204 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/snapshot.ts @@ -5,6 +5,7 @@ */ import { Snapshot } from '../../../common/runtime_types'; + export const FETCH_SNAPSHOT_COUNT = 'FETCH_SNAPSHOT_COUNT'; export const FETCH_SNAPSHOT_COUNT_FAIL = 'FETCH_SNAPSHOT_COUNT_FAIL'; export const FETCH_SNAPSHOT_COUNT_SUCCESS = 'FETCH_SNAPSHOT_COUNT_SUCCESS'; diff --git a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts index fb38599495d84..d15d601737b2d 100644 --- a/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/actions/ui.ts @@ -16,6 +16,8 @@ export const setBasePath = createAction('SET BASE PATH'); export const triggerAppRefresh = createAction('REFRESH APP'); +export const setEsKueryString = createAction('SET ES KUERY STRING'); + export const toggleIntegrationsPopover = createAction( 'TOGGLE INTEGRATION POPOVER STATE' ); diff --git a/x-pack/legacy/plugins/uptime/public/state/api/index.ts b/x-pack/legacy/plugins/uptime/public/state/api/index.ts index 0bcd29cf3fbe9..1d0cac5f87854 100644 --- a/x-pack/legacy/plugins/uptime/public/state/api/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/api/index.ts @@ -5,5 +5,6 @@ */ export * from './monitor'; +export * from './overview_filters'; export * from './snapshot'; export * from './monitor_status'; diff --git a/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts new file mode 100644 index 0000000000000..c3ef62fa88dcf --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/api/overview_filters.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { isRight } from 'fp-ts/lib/Either'; +import { GetOverviewFiltersPayload } from '../actions/overview_filters'; +import { getApiPath, parameterizeValues } from '../../lib/helper'; +import { OverviewFiltersType } from '../../../common/runtime_types'; + +type ApiRequest = GetOverviewFiltersPayload & { + basePath: string; +}; + +export const fetchOverviewFilters = async ({ + basePath, + dateRangeStart, + dateRangeEnd, + search, + schemes, + locations, + ports, + tags, +}: ApiRequest) => { + const url = getApiPath(`/api/uptime/filters`, basePath); + + const params = new URLSearchParams({ + dateRangeStart, + dateRangeEnd, + }); + + if (search) { + params.append('search', search); + } + + parameterizeValues(params, { schemes, locations, ports, tags }); + + const response = await fetch(`${url}?${params.toString()}`); + if (!response.ok) { + throw new Error(response.statusText); + } + const responseData = await response.json(); + const decoded = OverviewFiltersType.decode(responseData); + + ThrowReporter.report(decoded); + if (isRight(decoded)) { + return decoded.right; + } + throw new Error('`getOverviewFilters` response did not correspond to expected type'); +}; diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts new file mode 100644 index 0000000000000..d293cdbe451b5 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/fetch_effect.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { call, put, select } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { getBasePath } from '../selectors'; + +/** + * Factory function for a fetch effect. It expects three action creators, + * one to call for a fetch, one to call for success, and one to handle failures. + * @param fetch creates a fetch action + * @param success creates a success action + * @param fail creates a failure action + * @template T the action type expected by the fetch action + * @template R the type that the API request should return on success + * @template S tye type of the success action + * @template F the type of the failure action + */ +export function fetchEffectFactory( + fetch: (request: T) => Promise, + success: (response: R) => Action, + fail: (error: Error) => Action +) { + return function*(action: Action) { + try { + if (!action.payload) { + yield put(fail(new Error('Cannot fetch snapshot for undefined parameters.'))); + return; + } + const { + payload: { ...params }, + } = action; + const basePath = yield select(getBasePath); + const response = yield call(fetch, { ...params, basePath }); + yield put(success(response)); + } catch (error) { + yield put(fail(error)); + } + }; +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts index 936b38a4d5345..41dda145edb4e 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/index.ts @@ -6,11 +6,13 @@ import { fork } from 'redux-saga/effects'; import { fetchMonitorDetailsEffect } from './monitor'; -import { fetchSnapshotCountSaga } from './snapshot'; +import { fetchOverviewFiltersEffect } from './overview_filters'; +import { fetchSnapshotCountEffect } from './snapshot'; import { fetchMonitorStatusEffect } from './monitor_status'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); - yield fork(fetchSnapshotCountSaga); + yield fork(fetchSnapshotCountEffect); + yield fork(fetchOverviewFiltersEffect); yield fork(fetchMonitorStatusEffect); } diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts new file mode 100644 index 0000000000000..92b578bafed2d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/effects/overview_filters.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { takeLatest } from 'redux-saga/effects'; +import { + FETCH_OVERVIEW_FILTERS, + fetchOverviewFiltersFail, + fetchOverviewFiltersSuccess, +} from '../actions'; +import { fetchOverviewFilters } from '../api'; +import { fetchEffectFactory } from './fetch_effect'; + +export function* fetchOverviewFiltersEffect() { + yield takeLatest( + FETCH_OVERVIEW_FILTERS, + fetchEffectFactory(fetchOverviewFilters, fetchOverviewFiltersSuccess, fetchOverviewFiltersFail) + ); +} diff --git a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts index 23ac1016d2244..91df43dd9e826 100644 --- a/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts +++ b/x-pack/legacy/plugins/uptime/public/state/effects/snapshot.ts @@ -4,42 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { call, put, takeLatest, select } from 'redux-saga/effects'; -import { Action } from 'redux-actions'; +import { takeLatest } from 'redux-saga/effects'; import { FETCH_SNAPSHOT_COUNT, - GetSnapshotPayload, fetchSnapshotCountFail, fetchSnapshotCountSuccess, } from '../actions'; import { fetchSnapshotCount } from '../api'; -import { getBasePath } from '../selectors'; +import { fetchEffectFactory } from './fetch_effect'; -function* snapshotSaga(action: Action) { - try { - if (!action.payload) { - yield put( - fetchSnapshotCountFail(new Error('Cannot fetch snapshot for undefined parameters.')) - ); - return; - } - const { - payload: { dateRangeStart, dateRangeEnd, filters, statusFilter }, - } = action; - const basePath = yield select(getBasePath); - const response = yield call(fetchSnapshotCount, { - basePath, - dateRangeStart, - dateRangeEnd, - filters, - statusFilter, - }); - yield put(fetchSnapshotCountSuccess(response)); - } catch (error) { - yield put(fetchSnapshotCountFail(error)); - } -} - -export function* fetchSnapshotCountSaga() { - yield takeLatest(FETCH_SNAPSHOT_COUNT, snapshotSaga); +export function* fetchSnapshotCountEffect() { + yield takeLatest( + FETCH_SNAPSHOT_COUNT, + fetchEffectFactory(fetchSnapshotCount, fetchSnapshotCountSuccess, fetchSnapshotCountFail) + ); } diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index 155f7edbcbf33..5d03c0058c3c1 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -3,6 +3,7 @@ exports[`ui reducer adds integration popover status to state 1`] = ` Object { "basePath": "", + "esKuery": "", "integrationsPopoverOpen": Object { "id": "popover-2", "open": true, @@ -14,6 +15,7 @@ Object { exports[`ui reducer sets the application's base path 1`] = ` Object { "basePath": "yyz", + "esKuery": "", "integrationsPopoverOpen": null, "lastRefresh": 125, } @@ -22,6 +24,7 @@ Object { exports[`ui reducer updates the refresh value 1`] = ` Object { "basePath": "abc", + "esKuery": "", "integrationsPopoverOpen": null, "lastRefresh": 125, } diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index ff9b7c3f9e8a4..417095b64ba2d 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -15,6 +15,7 @@ describe('ui reducer', () => { uiReducer( { basePath: 'abc', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, @@ -32,6 +33,7 @@ describe('ui reducer', () => { uiReducer( { basePath: '', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, @@ -46,6 +48,7 @@ describe('ui reducer', () => { uiReducer( { basePath: 'abc', + esKuery: '', integrationsPopoverOpen: null, lastRefresh: 125, }, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts index 7b4000adc2d12..5f915d970e543 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/index.ts @@ -6,12 +6,14 @@ import { combineReducers } from 'redux'; import { monitorReducer } from './monitor'; +import { overviewFiltersReducer } from './overview_filters'; import { snapshotReducer } from './snapshot'; import { uiReducer } from './ui'; import { monitorStatusReducer } from './monitor_status'; export const rootReducer = combineReducers({ monitor: monitorReducer, + overviewFilters: overviewFiltersReducer, snapshot: snapshotReducer, ui: uiReducer, monitorStatus: monitorStatusReducer, diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts new file mode 100644 index 0000000000000..b219421f4f4dc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/overview_filters.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OverviewFilters } from '../../../common/runtime_types'; +import { + FETCH_OVERVIEW_FILTERS, + FETCH_OVERVIEW_FILTERS_FAIL, + FETCH_OVERVIEW_FILTERS_SUCCESS, + OverviewFiltersAction, +} from '../actions'; + +export interface OverviewFiltersState { + filters: OverviewFilters; + errors: Error[]; + loading: boolean; +} + +const initialState: OverviewFiltersState = { + filters: { + locations: [], + ports: [], + schemes: [], + tags: [], + }, + errors: [], + loading: false, +}; + +export function overviewFiltersReducer( + state = initialState, + action: OverviewFiltersAction +): OverviewFiltersState { + switch (action.type) { + case FETCH_OVERVIEW_FILTERS: + return { + ...state, + loading: true, + }; + case FETCH_OVERVIEW_FILTERS_SUCCESS: + return { + ...state, + filters: action.payload, + loading: false, + }; + case FETCH_OVERVIEW_FILTERS_FAIL: + return { + ...state, + errors: [...state.errors, action.payload], + }; + default: + return state; + } +} diff --git a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts index b23245aa65fca..bb5bd22085ac6 100644 --- a/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/legacy/plugins/uptime/public/state/reducers/ui.ts @@ -9,6 +9,7 @@ import { PopoverState, toggleIntegrationsPopover, setBasePath, + setEsKueryString, triggerAppRefresh, UiPayload, } from '../actions/ui'; @@ -16,12 +17,14 @@ import { export interface UiState { integrationsPopoverOpen: PopoverState | null; basePath: string; + esKuery: string; lastRefresh: number; } const initialState: UiState = { integrationsPopoverOpen: null, basePath: '', + esKuery: '', lastRefresh: Date.now(), }; @@ -41,6 +44,11 @@ export const uiReducer = handleActions( ...state, lastRefresh: action.payload as number, }), + + [String(setEsKueryString)]: (state, action: Action) => ({ + ...state, + esKuery: action.payload as string, + }), }, initialState ); diff --git a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts index df8e640bcbab5..38fb3edea4768 100644 --- a/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/legacy/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -9,6 +9,16 @@ import { AppState } from '../../../state'; describe('state selectors', () => { const state: AppState = { + overviewFilters: { + filters: { + locations: [], + ports: [], + schemes: [], + tags: [], + }, + errors: [], + loading: false, + }, monitor: { monitorDetailsList: [], monitorLocationsList: new Map(), @@ -24,7 +34,12 @@ describe('state selectors', () => { errors: [], loading: false, }, - ui: { basePath: 'yyz', integrationsPopoverOpen: null, lastRefresh: 125 }, + ui: { + basePath: 'yyz', + esKuery: '', + integrationsPopoverOpen: null, + lastRefresh: 125, + }, monitorStatus: { status: null, monitor: null, diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts index d53bf6b701500..897d67dde807e 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/resolvers.ts @@ -7,7 +7,6 @@ import { UMGqlRange } from '../../../common/domain_types'; import { UMResolver } from '../../../common/graphql/resolver_types'; import { - FilterBar, GetFilterBarQueryArgs, GetMonitorChartsDataQueryArgs, MonitorChart, @@ -46,7 +45,6 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( Query: { getSnapshotHistogram: UMGetSnapshotHistogram; getMonitorChartsData: UMGetMonitorChartsResolver; - getFilterBar: UMGetFilterBarResolver; }; } => ({ Query: { @@ -77,16 +75,5 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = ( location, }); }, - async getFilterBar( - _resolver, - { dateRangeStart, dateRangeEnd }, - { APICaller } - ): Promise { - return await libs.monitors.getFilterBar({ - callES: APICaller, - dateRangeStart, - dateRangeEnd, - }); - }, }, }); diff --git a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts index d49681f1dcd0d..8a86d97b4cd8e 100644 --- a/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts +++ b/x-pack/legacy/plugins/uptime/server/graphql/monitors/schema.gql.ts @@ -7,22 +7,6 @@ import gql from 'graphql-tag'; export const monitorsSchema = gql` - "The data used to enrich the filter bar." - type FilterBar { - "A series of monitor IDs in the heartbeat indices." - ids: [String!] - "The location values users have configured for the agents." - locations: [String!] - "The ports of the monitored endpoints." - ports: [Int!] - "The schemes used by the monitors." - schemes: [String!] - "The possible status values contained in the indices." - statuses: [String!] - "The list of URLs" - urls: [String!] - } - type HistogramDataPoint { upCount: Int downCount: Int @@ -136,19 +120,5 @@ export const monitorsSchema = gql` dateRangeEnd: String! location: String ): MonitorChart - - "Fetch the most recent event data for a monitor ID, date range, location." - getLatestMonitors( - "The lower limit of the date range." - dateRangeStart: String! - "The upper limit of the date range." - dateRangeEnd: String! - "Optional: a specific monitor ID filter." - monitorId: String - "Optional: a specific instance location filter." - location: String - ): [Ping!]! - - getFilterBar(dateRangeStart: String!, dateRangeEnd: String!): FilterBar } `; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap new file mode 100644 index 0000000000000..2f6d6e06f93e1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/extract_filter_aggs_results.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extractFilterAggsResults extracts the bucket values of the expected filter fields 1`] = ` +Object { + "locations": Array [ + "us-east-2", + "fairbanks", + ], + "ports": Array [ + 12349, + 80, + 5601, + 8200, + 9200, + 9292, + ], + "schemes": Array [ + "http", + "tcp", + "icmp", + ], + "tags": Array [ + "api", + "dev", + ], +} +`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap new file mode 100644 index 0000000000000..0f7abf5050bca --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/__snapshots__/generate_filter_aggs.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateFilterAggs generates expected aggregations object 1`] = ` +Object { + "locations": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "observer.geo.name", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, + "ports": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "url.port", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, + "schemes": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "monitor.type", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "tags": "api", + }, + }, + ], + }, + }, + }, + "tags": Object { + "aggs": Object { + "term": Object { + "terms": Object { + "field": "tags", + }, + }, + }, + "filter": Object { + "bool": Object { + "should": Array [ + Object { + "term": Object { + "observer.geo.name": "fairbanks", + }, + }, + Object { + "term": Object { + "observer.geo.name": "us-east-2", + }, + }, + Object { + "term": Object { + "url.port": "80", + }, + }, + Object { + "term": Object { + "url.port": "5601", + }, + }, + Object { + "term": Object { + "monitor.type": "http", + }, + }, + Object { + "term": Object { + "monitor.type": "tcp", + }, + }, + ], + }, + }, + }, +} +`; diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts new file mode 100644 index 0000000000000..2075b3a8fbe0f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/combine_range_with_filters.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { combineRangeWithFilters } from '../elasticsearch_monitors_adapter'; + +describe('combineRangeWithFilters', () => { + it('combines filters that have no filter clause', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { should: [{ match: { 'url.port': 80 } }], minimum_should_match: 1 }, + }) + ).toEqual({ + bool: { + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + filter: [ + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + }, + }); + }); + + it('combines query with filter object', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { + filter: { term: { field: 'monitor.id' } }, + should: [{ match: { 'url.port': 80 } }], + minimum_should_match: 1, + }, + }) + ).toEqual({ + bool: { + filter: [ + { + field: 'monitor.id', + }, + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + + it('combines query with filter list', () => { + expect( + combineRangeWithFilters('now-15m', 'now', { + bool: { + filter: [{ field: 'monitor.id' }], + should: [{ match: { 'url.port': 80 } }], + minimum_should_match: 1, + }, + }) + ).toEqual({ + bool: { + filter: [ + { + field: 'monitor.id', + }, + { + range: { + '@timestamp': { + gte: 'now-15m', + lte: 'now', + }, + }, + }, + ], + should: [ + { + match: { + 'url.port': 80, + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts new file mode 100644 index 0000000000000..954cffd4c9522 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/extract_filter_aggs_results.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extractFilterAggsResults } from '../elasticsearch_monitors_adapter'; + +describe('extractFilterAggsResults', () => { + it('extracts the bucket values of the expected filter fields', () => { + expect( + extractFilterAggsResults( + { + locations: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'us-east-2', doc_count: 4050 }, + { key: 'fairbanks', doc_count: 4048 }, + ], + }, + }, + schemes: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'http', doc_count: 5055 }, + { key: 'tcp', doc_count: 2685 }, + { key: 'icmp', doc_count: 358 }, + ], + }, + }, + ports: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 12349, doc_count: 3571 }, + { key: 80, doc_count: 2985 }, + { key: 5601, doc_count: 358 }, + { key: 8200, doc_count: 358 }, + { key: 9200, doc_count: 358 }, + { key: 9292, doc_count: 110 }, + ], + }, + }, + tags: { + doc_count: 8098, + term: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'api', doc_count: 8098 }, + { key: 'dev', doc_count: 8098 }, + ], + }, + }, + }, + ['locations', 'ports', 'schemes', 'tags'] + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts new file mode 100644 index 0000000000000..4e285ec25a492 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/__tests__/generate_filter_aggs.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generateFilterAggs } from '../generate_filter_aggs'; + +describe('generateFilterAggs', () => { + it('generates expected aggregations object', () => { + expect( + generateFilterAggs( + [ + { aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' }, + { aggName: 'ports', filterName: 'ports', field: 'url.port' }, + { aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' }, + { aggName: 'tags', filterName: 'tags', field: 'tags' }, + ], + { + locations: ['fairbanks', 'us-east-2'], + ports: ['80', '5601'], + tags: ['api'], + schemes: ['http', 'tcp'], + } + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts index f844d6ecc1c4a..8523d9c75f51f 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/adapter_types.ts @@ -6,7 +6,11 @@ import { MonitorChart } from '../../../../common/graphql/types'; import { UMElasticsearchQueryFn } from '../framework'; -import { MonitorDetails, MonitorLocations } from '../../../../common/runtime_types'; +import { + MonitorDetails, + MonitorLocations, + OverviewFilters, +} from '../../../../common/runtime_types'; export interface GetMonitorChartsDataParams { /** @member monitorId ID value for the selected monitor */ @@ -20,9 +24,15 @@ export interface GetMonitorChartsDataParams { } export interface GetFilterBarParams { + /** @param dateRangeStart timestamp bounds */ dateRangeStart: string; /** @member dateRangeEnd timestamp bounds */ dateRangeEnd: string; + /** @member search this value should correspond to Elasticsearch DSL + * generated from KQL text the user provided. + */ + search?: Record; + filterOptions: Record; } export interface GetMonitorDetailsParams { @@ -48,10 +58,13 @@ export interface UMMonitorsAdapter { * Fetches data used to populate monitor charts */ getMonitorChartsData: UMElasticsearchQueryFn; - getFilterBar: UMElasticsearchQueryFn; + /** - * Fetch data for the monitor page title. + * Fetch options for the filter bar. */ + getFilterBar: UMElasticsearchQueryFn; + getMonitorDetails: UMElasticsearchQueryFn; + getMonitorLocations: UMElasticsearchQueryFn; } diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index a452f2364540a..37a9e032cd442 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 @@ -10,6 +10,52 @@ import { MonitorChart, LocationDurationLine } from '../../../../common/graphql/t import { getHistogramIntervalFormatted } from '../../helper'; import { MonitorError, MonitorLocation } from '../../../../common/runtime_types'; import { UMMonitorsAdapter } from './adapter_types'; +import { generateFilterAggs } from './generate_filter_aggs'; +import { OverviewFilters } from '../../../../common/runtime_types'; + +export const combineRangeWithFilters = ( + dateRangeStart: string, + dateRangeEnd: string, + filters?: Record +) => { + const range = { + range: { + '@timestamp': { + gte: dateRangeStart, + lte: dateRangeEnd, + }, + }, + }; + if (!filters) return range; + const clientFiltersList = Array.isArray(filters?.bool?.filter ?? {}) + ? // i.e. {"bool":{"filter":{ ...some nested filter objects }}} + filters.bool.filter + : // i.e. {"bool":{"filter":[ ...some listed filter objects ]}} + Object.keys(filters?.bool?.filter ?? {}).map(key => ({ + ...filters?.bool?.filter?.[key], + })); + filters.bool.filter = [...clientFiltersList, range]; + return filters; +}; + +type SupportedFields = 'locations' | 'ports' | 'schemes' | 'tags'; + +export const extractFilterAggsResults = ( + responseAggregations: Record, + keys: SupportedFields[] +): OverviewFilters => { + const values: OverviewFilters = { + locations: [], + ports: [], + schemes: [], + tags: [], + }; + keys.forEach(key => { + const buckets = responseAggregations[key]?.term?.buckets ?? []; + values[key] = buckets.map((item: { key: string | number }) => item.key); + }); + return values; +}; const formatStatusBuckets = (time: any, buckets: any, docCount: any) => { let up = null; @@ -160,39 +206,30 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { return monitorChartsData; }, - getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd }) => { - const fields: { [key: string]: string } = { - ids: 'monitor.id', - schemes: 'monitor.type', - urls: 'url.full', - ports: 'url.port', - locations: 'observer.geo.name', - }; + getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd, search, filterOptions }) => { + const aggs = generateFilterAggs( + [ + { aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' }, + { aggName: 'ports', filterName: 'ports', field: 'url.port' }, + { aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' }, + { aggName: 'tags', filterName: 'tags', field: 'tags' }, + ], + filterOptions + ); + const filters = combineRangeWithFilters(dateRangeStart, dateRangeEnd, search); const params = { index: INDEX_NAMES.HEARTBEAT, body: { size: 0, query: { - range: { - '@timestamp': { - gte: dateRangeStart, - lte: dateRangeEnd, - }, - }, + ...filters, }, - aggs: Object.values(fields).reduce((acc: { [key: string]: any }, field) => { - acc[field] = { terms: { field, size: 20 } }; - return acc; - }, {}), + aggs, }, }; - const { aggregations } = await callES('search', params); - return Object.keys(fields).reduce((acc: { [key: string]: any[] }, field) => { - const bucketName = fields[field]; - acc[field] = aggregations[bucketName].buckets.map((b: { key: string | number }) => b.key); - return acc; - }, {}); + const { aggregations } = await callES('search', params); + return extractFilterAggsResults(aggregations, ['tags', 'locations', 'ports', 'schemes']); }, getMonitorDetails: async ({ callES, monitorId, dateStart, dateEnd }) => { diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts new file mode 100644 index 0000000000000..26d412e33c868 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/generate_filter_aggs.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface AggDefinition { + aggName: string; + filterName: string; + field: string; +} + +export const FIELD_MAPPINGS: Record = { + schemes: 'monitor.type', + ports: 'url.port', + locations: 'observer.geo.name', + tags: 'tags', +}; + +const getFilterAggConditions = (filterTerms: Record, except: string) => { + const filters: any[] = []; + + Object.keys(filterTerms).forEach((key: string) => { + if (key === except && FIELD_MAPPINGS[key]) return; + filters.push( + ...filterTerms[key].map(value => ({ + term: { + [FIELD_MAPPINGS[key]]: value, + }, + })) + ); + }); + + return filters; +}; + +export const generateFilterAggs = ( + aggDefinitions: AggDefinition[], + filterOptions: Record +) => + aggDefinitions + .map(({ aggName, filterName, field }) => ({ + [aggName]: { + filter: { + bool: { + should: [...getFilterAggConditions(filterOptions, filterName)], + }, + }, + aggs: { + term: { + terms: { + field, + }, + }, + }, + }, + })) + .reduce((parent: Record, agg: any) => ({ ...parent, ...agg }), {}); diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts index 4c88da7eca85a..f9a8de81332d5 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/index.ts @@ -9,3 +9,4 @@ export { getHistogramInterval } from './get_histogram_interval'; export { getHistogramIntervalFormatted } from './get_histogram_interval_formatted'; export { parseFilterQuery } from './parse_filter_query'; export { assertCloseTo } from './assert_close_to'; +export { objectValuesToArrays } from './object_to_array'; diff --git a/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts b/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts new file mode 100644 index 0000000000000..334c31c822eaa --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/lib/helper/object_to_array.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Converts the top-level fields of an object from an object to an array. + * @param record the obect to map + * @type T the type of the objects/arrays that will be mapped + */ +export const objectValuesToArrays = (record: Record): Record => { + const obj: Record = {}; + Object.keys(record).forEach((key: string) => { + const value = record[key]; + obj[key] = value ? (Array.isArray(value) ? value : [value]) : []; + }); + return obj; +}; diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts index 4bc6fe699a21a..e64b317e67f98 100644 --- a/x-pack/legacy/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/legacy/plugins/uptime/server/rest_api/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createGetOverviewFilters } from './overview_filters'; import { createGetAllRoute } from './pings'; import { createGetIndexPatternRoute } from './index_pattern'; import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry'; @@ -20,6 +21,7 @@ export * from './types'; export { createRouteWithAuth } from './create_route_with_auth'; export { uptimeRouteWrapper } from './uptime_route_wrapper'; export const restApiRoutes: UMRestApiRouteFactory[] = [ + createGetOverviewFilters, createGetAllRoute, createGetIndexPatternRoute, createGetMonitorRoute, diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts new file mode 100644 index 0000000000000..ef93253bb5b70 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/get_overview_filters.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; +import { objectValuesToArrays } from '../../lib/helper'; + +const arrayOrStringType = schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) +); + +export const createGetOverviewFilters: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/filters', + validate: { + query: schema.object({ + dateRangeStart: schema.string(), + dateRangeEnd: schema.string(), + search: schema.maybe(schema.string()), + locations: arrayOrStringType, + schemes: arrayOrStringType, + ports: arrayOrStringType, + tags: arrayOrStringType, + }), + }, + + options: { + tags: ['access:uptime'], + }, + handler: async ({ callES }, _context, request, response) => { + const { dateRangeStart, dateRangeEnd, locations, schemes, search, ports, tags } = request.query; + + let parsedSearch: Record | undefined; + if (search) { + try { + parsedSearch = JSON.parse(search); + } catch (e) { + return response.badRequest({ body: { message: e.message } }); + } + } + + const filtersResponse = await libs.monitors.getFilterBar({ + callES, + dateRangeStart, + dateRangeEnd, + search: parsedSearch, + filterOptions: objectValuesToArrays({ + locations, + ports, + schemes, + tags, + }), + }); + + return response.ok({ body: { ...filtersResponse } }); + }, +}); diff --git a/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts new file mode 100644 index 0000000000000..dc4e0c66a8183 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/server/rest_api/overview_filters/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createGetOverviewFilters } from './get_overview_filters'; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts b/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts index 8cebe8ce26229..65648ae5f5a95 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts @@ -104,4 +104,5 @@ export type TestSubjects = | 'webhookPathInput' | 'webhookPortInput' | 'webhookMethodSelect' + | 'webhookSchemeSelect' | 'webhookUsernameInput'; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx index 36a5c150eead7..2800b0107da24 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx @@ -257,9 +257,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -324,9 +326,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -387,9 +391,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -461,9 +467,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -487,6 +495,7 @@ describe(' create route', () => { const METHOD = 'put'; const HOST = 'localhost'; const PORT = '9200'; + const SCHEME = 'http'; const PATH = '/test'; const USERNAME = 'test_user'; const PASSWORD = 'test_password'; @@ -510,6 +519,7 @@ describe(' create route', () => { form.setInputValue('webhookMethodSelect', METHOD); form.setInputValue('webhookHostInput', HOST); form.setInputValue('webhookPortInput', PORT); + form.setInputValue('webhookSchemeSelect', SCHEME); form.setInputValue('webhookPathInput', PATH); form.setInputValue('webhookUsernameInput', USERNAME); form.setInputValue('webhookPasswordInput', PASSWORD); @@ -534,6 +544,7 @@ describe(' create route', () => { method: METHOD, host: HOST, port: Number(PORT), + scheme: SCHEME, path: PATH, body: '{\n "message": "Watch [{{ctx.metadata.name}}] has exceeded the threshold"\n}', // Default @@ -551,9 +562,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -639,9 +652,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -707,9 +722,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; @@ -759,9 +776,11 @@ describe(' create route', () => { triggerIntervalUnit: 'm', aggType: 'count', termSize: 5, + termOrder: 'desc', thresholdComparator: '>', timeWindowSize: 5, timeWindowUnit: 'm', + hasTermsAgg: false, threshold: 1000, }; diff --git a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts index 1eee3d3b7e6ee..131400a8702c4 100644 --- a/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts +++ b/x-pack/legacy/plugins/watcher/__jest__/client_integration/watch_edit.test.ts @@ -168,6 +168,7 @@ describe('', () => { }); const latestRequest = server.requests[server.requests.length - 1]; + const { id, type, @@ -194,9 +195,11 @@ describe('', () => { triggerIntervalUnit, aggType, termSize, + termOrder: 'desc', thresholdComparator, timeWindowSize, timeWindowUnit, + hasTermsAgg: false, threshold: threshold && threshold[0], }) ); diff --git a/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js index 54be8407f207d..d6f921a75a9ea 100644 --- a/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/common/models/action/webhook_action.js @@ -16,6 +16,7 @@ export class WebhookAction extends BaseAction { this.method = props.method; this.host = props.host; this.port = props.port; + this.scheme = props.scheme; this.path = props.path; this.body = props.body; this.contentType = props.contentType; @@ -30,6 +31,7 @@ export class WebhookAction extends BaseAction { method: this.method, host: this.host, port: this.port, + scheme: this.scheme, path: this.path, body: this.body, contentType: this.contentType, @@ -47,6 +49,7 @@ export class WebhookAction extends BaseAction { method: json.method, host: json.host, port: json.port, + scheme: json.scheme, path: json.path, body: json.body, contentType: json.contentType, @@ -72,6 +75,10 @@ export class WebhookAction extends BaseAction { optionalFields.method = this.method; } + if (this.scheme) { + optionalFields.scheme = this.scheme; + } + if (this.body) { optionalFields.body = this.body; } @@ -108,7 +115,7 @@ export class WebhookAction extends BaseAction { const webhookJson = json && json.actionJson && json.actionJson.webhook; const { errors } = this.validateJson(json.actionJson); - const { path, method, body, auth, headers } = webhookJson; + const { path, method, scheme, body, auth, headers } = webhookJson; const optionalFields = {}; @@ -120,6 +127,10 @@ export class WebhookAction extends BaseAction { optionalFields.method = method; } + if (scheme) { + optionalFields.scheme = scheme; + } + if (body) { optionalFields.body = body; } diff --git a/x-pack/legacy/plugins/watcher/common/types/action_types.ts b/x-pack/legacy/plugins/watcher/common/types/action_types.ts index 123bf0f58db9d..918e9a933611b 100644 --- a/x-pack/legacy/plugins/watcher/common/types/action_types.ts +++ b/x-pack/legacy/plugins/watcher/common/types/action_types.ts @@ -56,6 +56,7 @@ export interface WebhookAction extends BaseAction { method?: 'head' | 'get' | 'post' | 'put' | 'delete'; host: string; port: number; + scheme?: 'http' | 'https'; path?: string; body?: string; username?: string; diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js index d46d9aacb035b..6f496dd9ee138 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js @@ -11,23 +11,21 @@ import { i18n } from '@kbn/i18n'; export class WebhookAction extends BaseAction { constructor(props = {}) { super(props); - const defaultJson = JSON.stringify( { message: 'Watch [{{ctx.metadata.name}}] has exceeded the threshold' }, null, 2 ); this.body = get(props, 'body', props.ignoreDefaults ? null : defaultJson); - this.method = get(props, 'method'); this.host = get(props, 'host'); this.port = get(props, 'port'); + this.scheme = get(props, 'scheme', 'http'); this.path = get(props, 'path'); this.username = get(props, 'username'); this.password = get(props, 'password'); this.contentType = get(props, 'contentType'); - - this.fullPath = `${this.host}:${this.port}${this.path}`; + this.fullPath = `${this.host}:${this.port}${this.path ? '/' + this.path : ''}`; } validate() { @@ -112,6 +110,7 @@ export class WebhookAction extends BaseAction { method: this.method, host: this.host, port: this.port, + scheme: this.scheme, path: this.path, body: this.body, username: this.username, diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js index 7611f158fb962..2383388dd89bf 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/watch/threshold_watch.js @@ -256,9 +256,11 @@ export class ThresholdWatch extends BaseWatch { aggField: this.aggField, termSize: this.termSize, termField: this.termField, + termOrder: this.termOrder, thresholdComparator: this.thresholdComparator, timeWindowSize: this.timeWindowSize, timeWindowUnit: this.timeWindowUnit, + hasTermsAgg: this.hasTermsAgg, threshold: comparators[this.thresholdComparator].requiredValues > 1 ? this.threshold diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx b/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx index bdc6f0bcbb717..be0b551f4a39c 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/sections/watch_edit/components/threshold_watch_edit/action_fields/webhook_action_fields.tsx @@ -29,13 +29,15 @@ interface Props { const HTTP_VERBS = ['head', 'get', 'post', 'put', 'delete']; +const SCHEME = ['http', 'https']; + export const WebhookActionFields: React.FunctionComponent = ({ action, editAction, errors, hasErrors, }) => { - const { method, host, port, path, body, username, password } = action; + const { method, host, port, scheme, path, body, username, password } = action; useEffect(() => { editAction({ key: 'contentType', value: 'application/json' }); // set content-type for threshold watch to json by default @@ -65,6 +67,27 @@ export const WebhookActionFields: React.FunctionComponent = ({ + + + ({ text: verb, value: verb }))} + onChange={e => { + editAction({ key: 'scheme', value: e.target.value }); + }} + /> + + + { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js index bf7889473b60d..c83fbc0b4564c 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/base_watch.test.js @@ -188,50 +188,6 @@ describe('BaseWatch', () => { }); }); - describe('upstreamJson getter method', () => { - let props; - beforeEach(() => { - props = { - id: 'foo', - name: 'bar', - type: 'json', - watchStatus: { - downstreamJson: { - prop1: 'prop1', - prop2: 'prop2', - }, - }, - actions: [ - { - downstreamJson: { - prop1: 'prop3', - prop2: 'prop4', - }, - }, - ], - }; - }); - - it('should return a valid object', () => { - const watch = new BaseWatch(props); - - const actual = watch.upstreamJson; - const expected = { - id: props.id, - watch: { - metadata: { - name: props.name, - xpack: { - type: props.type, - }, - }, - }, - }; - - expect(actual).toEqual(expected); - }); - }); - describe('getPropsFromDownstreamJson method', () => { let downstreamJson; beforeEach(() => { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js index e6d49f7adc19e..2440d4ef33881 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.js @@ -23,12 +23,6 @@ export class JsonWatch extends BaseWatch { return serializeJsonWatch(this.name, this.watch); } - // To Elasticsearch - get upstreamJson() { - const result = super.upstreamJson; - return result; - } - // To Kibana get downstreamJson() { const result = merge({}, super.downstreamJson, { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js index 56150667b609e..0301c4c95be94 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/json_watch.test.js @@ -52,26 +52,6 @@ describe('JsonWatch', () => { }); }); - describe('upstreamJson getter method', () => { - it('should return the correct result', () => { - const watch = new JsonWatch({ watch: { foo: 'bar' } }); - const actual = watch.upstreamJson; - const expected = { - id: undefined, - watch: { - foo: 'bar', - metadata: { - xpack: { - type: 'json', - }, - }, - }, - }; - - expect(actual).toEqual(expected); - }); - }); - describe('downstreamJson getter method', () => { let props; beforeEach(() => { diff --git a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js index 21909c488431f..e5410588ab566 100644 --- a/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js +++ b/x-pack/legacy/plugins/watcher/server/np_ready/models/watch/threshold_watch/threshold_watch.js @@ -55,12 +55,6 @@ export class ThresholdWatch extends BaseWatch { return formatVisualizeData(this, results); } - // To Elasticsearch - get upstreamJson() { - const result = super.upstreamJson; - return result; - } - // To Kibana get downstreamJson() { const result = merge({}, super.downstreamJson, { diff --git a/x-pack/package.json b/x-pack/package.json index ffa593f5728ee..1e20157831ba5 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -192,6 +192,7 @@ "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", "@turf/boolean-contains": "6.0.1", + "angular": "^1.7.9", "angular-resource": "1.7.8", "angular-sanitize": "1.7.8", "angular-ui-ace": "0.2.3", @@ -342,7 +343,7 @@ "uuid": "3.3.2", "venn.js": "0.2.20", "vscode-languageserver": "^5.2.1", - "webpack": "4.33.0", + "webpack": "4.41.0", "wellknown": "^0.5.0", "xml2js": "^0.4.22", "xregexp": "4.2.4" diff --git a/x-pack/plugins/endpoint/server/routes/endpoints.ts b/x-pack/plugins/endpoint/server/routes/endpoints.ts index 59430947d97da..9d2babc61f11f 100644 --- a/x-pack/plugins/endpoint/server/routes/endpoints.ts +++ b/x-pack/plugins/endpoint/server/routes/endpoints.ts @@ -66,6 +66,7 @@ function mapToEndpointResultList( queryParams: Record, searchResponse: SearchResponse ): EndpointResultList { + const totalNumberOfEndpoints = searchResponse?.aggregations?.total?.value || 0; if (searchResponse.hits.hits.length > 0) { return { request_page_size: queryParams.size, @@ -74,13 +75,13 @@ function mapToEndpointResultList( .map(response => response.inner_hits.most_recent.hits.hits) .flatMap(data => data as HitSource) .map(entry => entry._source), - total: searchResponse.aggregations.total.value, + total: totalNumberOfEndpoints, }; } else { return { request_page_size: queryParams.size, request_index: queryParams.from, - total: 0, + total: totalNumberOfEndpoints, endpoints: [], }; } diff --git a/x-pack/plugins/graph/server/routes/explore.ts b/x-pack/plugins/graph/server/routes/explore.ts index 0a5b9f62f12a1..125378891151b 100644 --- a/x-pack/plugins/graph/server/routes/explore.ts +++ b/x-pack/plugins/graph/server/routes/explore.ts @@ -71,7 +71,11 @@ export function registerExploreRoute({ throw Boom.badRequest(relevantCause.reason); } - throw Boom.boomify(error); + return response.internalError({ + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/graph/server/routes/search.ts b/x-pack/plugins/graph/server/routes/search.ts index 400cdc4e82b6e..91b404dc7cb91 100644 --- a/x-pack/plugins/graph/server/routes/search.ts +++ b/x-pack/plugins/graph/server/routes/search.ts @@ -6,7 +6,6 @@ import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { LicenseState, verifyApiAccess } from '../lib/license_state'; export function registerSearchRoute({ @@ -53,7 +52,12 @@ export function registerSearchRoute({ }, }); } catch (error) { - throw Boom.boomify(error, { statusCode: error.statusCode || 500 }); + return response.customError({ + statusCode: error.statusCode || 500, + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/security/common/licensing/license_features.ts b/x-pack/plugins/security/common/licensing/license_features.ts index 6b6c86d48c21e..33f8370a1b43e 100644 --- a/x-pack/plugins/security/common/licensing/license_features.ts +++ b/x-pack/plugins/security/common/licensing/license_features.ts @@ -23,6 +23,11 @@ export interface SecurityLicenseFeatures { */ readonly showLinks: boolean; + /** + * Indicates whether we show the Role Mappings UI. + */ + readonly showRoleMappingsManagement: boolean; + /** * Indicates whether we allow users to define document level security in roles. */ diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index f4fa5e00e2387..df2d66a036039 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -17,6 +17,7 @@ describe('license features', function() { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, layout: 'error-es-unavailable', @@ -34,6 +35,7 @@ describe('license features', function() { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, layout: 'error-xpack-unavailable', @@ -63,6 +65,7 @@ describe('license features', function() { "layout": "error-xpack-unavailable", "showLinks": false, "showLogin": true, + "showRoleMappingsManagement": false, }, ] `); @@ -79,6 +82,7 @@ describe('license features', function() { "linksMessage": "Access is denied because Security is disabled in Elasticsearch.", "showLinks": false, "showLogin": false, + "showRoleMappingsManagement": false, }, ] `); @@ -87,10 +91,12 @@ describe('license features', function() { } }); - it('should show login page and other security elements, allow RBAC but forbid document level security if license is not platinum or trial.', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); + it('should show login page and other security elements, allow RBAC but forbid role mappings and document level security if license is basic.', () => { + const mockRawLicense = licensingMock.createLicense({ + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const getFeatureSpy = jest.spyOn(mockRawLicense, 'getFeature'); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -99,18 +105,19 @@ describe('license features', function() { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, }); - expect(mockRawLicense.getFeature).toHaveBeenCalledTimes(1); - expect(mockRawLicense.getFeature).toHaveBeenCalledWith('security'); + expect(getFeatureSpy).toHaveBeenCalledTimes(1); + expect(getFeatureSpy).toHaveBeenCalledWith('security'); }); it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true }); + const mockRawLicense = licensingMock.createLicense({ + features: { security: { isEnabled: false, isAvailable: true } }, + }); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -119,6 +126,7 @@ describe('license features', function() { showLogin: false, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -126,12 +134,31 @@ describe('license features', function() { }); }); - it('should allow to login, allow RBAC and document level security if license >= platinum', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockImplementation(license => { - return license === 'trial' || license === 'platinum' || license === 'enterprise'; + it('should allow role mappings, but not DLS/FLS if license = gold', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'gold', type: 'gold' }, + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const serviceSetup = new SecurityLicenseService().setup({ + license$: of(mockRawLicense), + }); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: true, + showLinks: true, + showRoleMappingsManagement: true, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: true, + }); + }); + + it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'platinum', type: 'platinum' }, + features: { security: { isEnabled: true, isAvailable: true } }, }); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -140,6 +167,7 @@ describe('license features', function() { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement: true, allowRoleDocumentLevelSecurity: true, allowRoleFieldLevelSecurity: true, allowRbac: true, diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 0f9da03f9f6ec..e6d2eff49ed0d 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -70,6 +70,7 @@ export class SecurityLicenseService { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -85,6 +86,7 @@ export class SecurityLicenseService { showLogin: false, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -92,11 +94,13 @@ export class SecurityLicenseService { }; } + const showRoleMappingsManagement = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter, allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 226ea3b70afe2..f3c65ed7e3cf1 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -12,3 +12,10 @@ export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; export { KibanaPrivileges } from './kibana_privileges'; +export { + InlineRoleTemplate, + StoredRoleTemplate, + InvalidRoleTemplate, + RoleTemplate, + RoleMapping, +} from './role_mapping'; diff --git a/x-pack/plugins/security/common/model/role_mapping.ts b/x-pack/plugins/security/common/model/role_mapping.ts new file mode 100644 index 0000000000000..99de183f648f7 --- /dev/null +++ b/x-pack/plugins/security/common/model/role_mapping.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface RoleMappingAnyRule { + any: RoleMappingRule[]; +} + +interface RoleMappingAllRule { + all: RoleMappingRule[]; +} + +interface RoleMappingFieldRule { + field: Record; +} + +interface RoleMappingExceptRule { + except: RoleMappingRule; +} + +type RoleMappingRule = + | RoleMappingAnyRule + | RoleMappingAllRule + | RoleMappingFieldRule + | RoleMappingExceptRule; + +type RoleTemplateFormat = 'string' | 'json'; + +export interface InlineRoleTemplate { + template: { source: string }; + format?: RoleTemplateFormat; +} + +export interface StoredRoleTemplate { + template: { id: string }; + format?: RoleTemplateFormat; +} + +export interface InvalidRoleTemplate { + template: string; + format?: RoleTemplateFormat; +} + +export type RoleTemplate = InlineRoleTemplate | StoredRoleTemplate | InvalidRoleTemplate; + +export interface RoleMapping { + name: string; + enabled: boolean; + roles?: string[]; + role_templates?: RoleTemplate[]; + rules: RoleMappingRule | {}; + metadata: Record; +} diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts index 60d947bd65863..996dcb685f29b 100644 --- a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -573,4 +573,64 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen fmt: '/_security/delegate_pki', }, }); + + /** + * Retrieves all configured role mappings. + * + * @returns {{ [roleMappingName]: { enabled: boolean; roles: string[]; rules: Record} }} + */ + shield.getRoleMappings = ca({ + method: 'GET', + urls: [ + { + fmt: '/_security/role_mapping', + }, + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + /** + * Saves the specified role mapping. + */ + shield.saveRoleMapping = ca({ + method: 'POST', + needBody: true, + urls: [ + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + /** + * Deletes the specified role mapping. + */ + shield.deleteRoleMapping = ca({ + method: 'DELETE', + urls: [ + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); } diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index ade840e7ca495..01df67cacb800 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -14,6 +14,7 @@ import { defineAuthorizationRoutes } from './authorization'; import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; +import { defineRoleMappingRoutes } from './role_mapping'; /** * Describes parameters used to define HTTP routes. @@ -35,4 +36,5 @@ export function defineRoutes(params: RouteDefinitionParams) { defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); + defineRoleMappingRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts new file mode 100644 index 0000000000000..e8a8a7216330b --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { defineRoleMappingDeleteRoutes } from './delete'; + +describe('DELETE role mappings', () => { + it('allows a role mapping to be deleted', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ acknowledged: true }); + + defineRoleMappingDeleteRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ acknowledged: true }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect( + mockScopedClusterClient.callAsCurrentUser + ).toHaveBeenCalledWith('shield.deleteRoleMapping', { name }); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingDeleteRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.ts new file mode 100644 index 0000000000000..dc11bcd914b35 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingDeleteRoutes(params: RouteDefinitionParams) { + const { clusterClient, router } = params; + + router.delete( + { + path: '/internal/security/role_mapping/{name}', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const deleteResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.deleteRoleMapping', { + name: request.params.name, + }); + return response.ok({ body: deleteResponse }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts b/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts new file mode 100644 index 0000000000000..f2c48fd370434 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + kibanaResponseFactory, + RequestHandlerContext, + IClusterClient, +} from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server'; +import { defineRoleMappingFeatureCheckRoute } from './feature_check'; + +interface TestOptions { + licenseCheckResult?: LicenseCheck; + canManageRoleMappings?: boolean; + nodeSettingsResponse?: Record; + xpackUsageResponse?: Record; + internalUserClusterClientImpl?: IClusterClient['callAsInternalUser']; + asserts: { statusCode: number; result?: Record }; +} + +const defaultXpackUsageResponse = { + security: { + realms: { + native: { + available: true, + enabled: true, + }, + pki: { + available: true, + enabled: true, + }, + }, + }, +}; + +const getDefaultInternalUserClusterClientImpl = ( + nodeSettingsResponse: TestOptions['nodeSettingsResponse'], + xpackUsageResponse: TestOptions['xpackUsageResponse'] +) => + ((async (endpoint: string, clientParams: Record) => { + if (!clientParams) throw new TypeError('expected clientParams'); + + if (endpoint === 'nodes.info') { + return nodeSettingsResponse; + } + + if (endpoint === 'transport.request') { + if (clientParams.path === '/_xpack/usage') { + return xpackUsageResponse; + } + } + + throw new Error(`unexpected endpoint: ${endpoint}`); + }) as unknown) as TestOptions['internalUserClusterClientImpl']; + +describe('GET role mappings feature check', () => { + const getFeatureCheckTest = ( + description: string, + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + canManageRoleMappings = true, + nodeSettingsResponse = {}, + xpackUsageResponse = defaultXpackUsageResponse, + internalUserClusterClientImpl = getDefaultInternalUserClusterClientImpl( + nodeSettingsResponse, + xpackUsageResponse + ), + asserts, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockRouteDefinitionParams.clusterClient.callAsInternalUser.mockImplementation( + internalUserClusterClientImpl + ); + + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method, payload) => { + if (method === 'shield.hasPrivileges') { + return { + has_all_requested: canManageRoleMappings, + }; + } + }); + + defineRoleMappingFeatureCheckRoute(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/_check_role_mapping_features`, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + getFeatureCheckTest('allows both script types with the default settings', { + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('allows both script types when explicitly enabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['stored', 'inline'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('disallows stored scripts when disabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['inline'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: false, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('disallows inline scripts when disabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['stored'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: false, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('indicates incompatible realms when only native and file are enabled', { + xpackUsageResponse: { + security: { + realms: { + native: { + available: true, + enabled: true, + }, + file: { + available: true, + enabled: true, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: false, + }, + }, + }); + + getFeatureCheckTest('indicates canManageRoleMappings=false for users without `manage_security`', { + canManageRoleMappings: false, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: false, + }, + }, + }); + + getFeatureCheckTest( + 'falls back to allowing both script types if there is an error retrieving node settings', + { + internalUserClusterClientImpl: (() => { + return Promise.reject(new Error('something bad happened')); + }) as TestOptions['internalUserClusterClientImpl'], + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: false, + }, + }, + } + ); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts b/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts new file mode 100644 index 0000000000000..2be4f4cd89177 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger, IClusterClient } from 'src/core/server'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +interface NodeSettingsResponse { + nodes: { + [nodeId: string]: { + settings: { + script: { + allowed_types?: string[]; + allowed_contexts?: string[]; + }; + }; + }; + }; +} + +interface XPackUsageResponse { + security: { + realms: { + [realmName: string]: { + available: boolean; + enabled: boolean; + }; + }; + }; +} + +const INCOMPATIBLE_REALMS = ['file', 'native']; + +export function defineRoleMappingFeatureCheckRoute({ + router, + clusterClient, + logger, +}: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/_check_role_mapping_features', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { has_all_requested: canManageRoleMappings } = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.hasPrivileges', { + body: { + cluster: ['manage_security'], + }, + }); + + if (!canManageRoleMappings) { + return response.ok({ + body: { + canManageRoleMappings, + }, + }); + } + + const enabledFeatures = await getEnabledRoleMappingsFeatures(clusterClient, logger); + + return response.ok({ + body: { + ...enabledFeatures, + canManageRoleMappings, + }, + }); + }) + ); +} + +async function getEnabledRoleMappingsFeatures(clusterClient: IClusterClient, logger: Logger) { + logger.debug(`Retrieving role mappings features`); + + const nodeScriptSettingsPromise: Promise = clusterClient + .callAsInternalUser('nodes.info', { + filterPath: 'nodes.*.settings.script', + }) + .catch(error => { + // fall back to assuming that node settings are unset/at their default values. + // this will allow the role mappings UI to permit both role template script types, + // even if ES will disallow it at mapping evaluation time. + logger.error(`Error retrieving node settings for role mappings: ${error}`); + return {}; + }); + + const xpackUsagePromise: Promise = clusterClient + // `transport.request` is potentially unsafe when combined with untrusted user input. + // Do not augment with such input. + .callAsInternalUser('transport.request', { + method: 'GET', + path: '/_xpack/usage', + }) + .catch(error => { + // fall back to no external realms configured. + // this will cause a warning in the UI about no compatible realms being enabled, but will otherwise allow + // the mappings screen to function correctly. + logger.error(`Error retrieving XPack usage info for role mappings: ${error}`); + return { + security: { + realms: {}, + }, + } as XPackUsageResponse; + }); + + const [nodeScriptSettings, xpackUsage] = await Promise.all([ + nodeScriptSettingsPromise, + xpackUsagePromise, + ]); + + let canUseStoredScripts = true; + let canUseInlineScripts = true; + if (usesCustomScriptSettings(nodeScriptSettings)) { + canUseStoredScripts = Object.values(nodeScriptSettings.nodes).some(node => { + const allowedTypes = node.settings.script.allowed_types; + return !allowedTypes || allowedTypes.includes('stored'); + }); + + canUseInlineScripts = Object.values(nodeScriptSettings.nodes).some(node => { + const allowedTypes = node.settings.script.allowed_types; + return !allowedTypes || allowedTypes.includes('inline'); + }); + } + + const hasCompatibleRealms = Object.entries(xpackUsage.security.realms).some( + ([realmName, realm]) => { + return !INCOMPATIBLE_REALMS.includes(realmName) && realm.available && realm.enabled; + } + ); + + return { + hasCompatibleRealms, + canUseStoredScripts, + canUseInlineScripts, + }; +} + +function usesCustomScriptSettings( + nodeResponse: NodeSettingsResponse | {} +): nodeResponse is NodeSettingsResponse { + return nodeResponse.hasOwnProperty('nodes'); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.test.ts b/x-pack/plugins/security/server/routes/role_mapping/get.test.ts new file mode 100644 index 0000000000000..c60d5518097ba --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/get.test.ts @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { defineRoleMappingGetRoutes } from './get'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; + +const mockRoleMappingResponse = { + mapping1: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + mapping2: { + enabled: true, + role_templates: [{ template: JSON.stringify({ source: 'foo_{{username}}' }) }], + rules: { + any: [ + { + field: { + dn: 'CN=admin,OU=example,O=com', + }, + }, + { + field: { + username: 'admin_*', + }, + }, + ], + }, + }, + mapping3: { + enabled: true, + role_templates: [{ template: 'template with invalid json' }], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, +}; + +describe('GET role mappings', () => { + it('returns all role mappings', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockRoleMappingResponse); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual([ + { + name: 'mapping1', + enabled: true, + roles: ['foo', 'bar'], + role_templates: [], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + { + name: 'mapping2', + enabled: true, + role_templates: [{ template: { source: 'foo_{{username}}' } }], + rules: { + any: [ + { + field: { + dn: 'CN=admin,OU=example,O=com', + }, + }, + { + field: { + username: 'admin_*', + }, + }, + ], + }, + }, + { + name: 'mapping3', + enabled: true, + role_templates: [{ template: 'template with invalid json' }], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + ]); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getRoleMappings', + { name: undefined } + ); + }); + + it('returns role mapping by name', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ + mapping1: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + }); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ + name: 'mapping1', + enabled: true, + roles: ['foo', 'bar'], + role_templates: [], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getRoleMappings', + { name } + ); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + + it('returns a 404 when the role mapping is not found', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + Boom.notFound('role mapping not found!') + ); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(404); + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect( + mockScopedClusterClient.callAsCurrentUser + ).toHaveBeenCalledWith('shield.getRoleMappings', { name }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.ts b/x-pack/plugins/security/server/routes/role_mapping/get.ts new file mode 100644 index 0000000000000..9cd5cf83092e1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/get.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { RoleMapping } from '../../../../../legacy/plugins/security/common/model'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +interface RoleMappingsResponse { + [roleMappingName: string]: Omit; +} + +export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { + const { clusterClient, logger, router } = params; + + router.get( + { + path: '/internal/security/role_mapping/{name?}', + validate: { + params: schema.object({ + name: schema.maybe(schema.string()), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const expectSingleEntity = typeof request.params.name === 'string'; + + try { + const roleMappingsResponse: RoleMappingsResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRoleMappings', { + name: request.params.name, + }); + + const mappings = Object.entries(roleMappingsResponse).map(([name, mapping]) => { + return { + name, + ...mapping, + role_templates: (mapping.role_templates || []).map(entry => { + return { + ...entry, + template: tryParseRoleTemplate(entry.template as string), + }; + }), + } as RoleMapping; + }); + + if (expectSingleEntity) { + return response.ok({ body: mappings[0] }); + } + return response.ok({ body: mappings }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); + + /** + * While role templates are normally persisted as objects via the create API, they are stored internally as strings. + * As a result, the ES APIs to retrieve role mappings represent the templates as strings, so we have to attempt + * to parse them back out. ES allows for invalid JSON to be stored, so we have to account for that as well. + * + * @param roleTemplate the string-based template to parse + */ + function tryParseRoleTemplate(roleTemplate: string) { + try { + return JSON.parse(roleTemplate); + } catch (e) { + logger.debug(`Role template is not valid JSON: ${e}`); + return roleTemplate; + } + } +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/index.ts b/x-pack/plugins/security/server/routes/role_mapping/index.ts new file mode 100644 index 0000000000000..1bd90e8c1fae3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { defineRoleMappingFeatureCheckRoute } from './feature_check'; +import { defineRoleMappingGetRoutes } from './get'; +import { defineRoleMappingPostRoutes } from './post'; +import { defineRoleMappingDeleteRoutes } from './delete'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingRoutes(params: RouteDefinitionParams) { + defineRoleMappingFeatureCheckRoute(params); + defineRoleMappingGetRoutes(params); + defineRoleMappingPostRoutes(params); + defineRoleMappingDeleteRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.test.ts b/x-pack/plugins/security/server/routes/role_mapping/post.test.ts new file mode 100644 index 0000000000000..7d820d668a6da --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/post.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { defineRoleMappingPostRoutes } from './post'; + +describe('POST role mappings', () => { + it('allows a role mapping to be created', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ created: true }); + + defineRoleMappingPostRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + body: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ created: true }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.saveRoleMapping', + { + name, + body: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + } + ); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingPostRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.ts b/x-pack/plugins/security/server/routes/role_mapping/post.ts new file mode 100644 index 0000000000000..bf9112be4ad3f --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/post.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingPostRoutes(params: RouteDefinitionParams) { + const { clusterClient, router } = params; + + router.post( + { + path: '/internal/security/role_mapping/{name}', + validate: { + params: schema.object({ + name: schema.string(), + }), + body: schema.object({ + roles: schema.arrayOf(schema.string(), { defaultValue: [] }), + role_templates: schema.arrayOf( + schema.object({ + // Not validating `template` because the ES API currently accepts invalid payloads here. + // We allow this as well so that existing mappings can be updated via our Role Management UI + template: schema.any(), + format: schema.maybe( + schema.oneOf([schema.literal('string'), schema.literal('json')]) + ), + }), + { defaultValue: [] } + ), + enabled: schema.boolean(), + // Also lax on validation here because the real rules get quite complex, + // and keeping this in sync (and testable!) with ES could prove problematic. + // We do not interpret any of these rules within this route handler; + // they are simply passed to ES for processing. + rules: schema.object({}, { allowUnknowns: true }), + metadata: schema.object({}, { allowUnknowns: true }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const saveResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.saveRoleMapping', { + name: request.params.name, + body: request.body, + }); + return response.ok({ body: saveResponse }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9712c7c347303..3b0c188318309 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -441,9 +441,6 @@ "common.ui.flotCharts.tueLabel": "火", "common.ui.flotCharts.wedLabel": "水", "common.ui.management.breadcrumb": "管理", - "management.connectDataDisplayName": "データに接続", - "management.displayName": "管理", - "management.nav.menu": "管理メニュー", "common.ui.modals.cancelButtonLabel": "キャンセル", "common.ui.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストが接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", @@ -521,6 +518,7 @@ "common.ui.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} と {lt} {to}", "management.connectDataDisplayName": "データに接続", "management.displayName": "管理", + "management.nav.menu": "管理メニュー", "management.editIndexPattern.createIndex.defaultButtonDescription": "すべてのデータに完全集約を実行", "management.editIndexPattern.createIndex.defaultButtonText": "標準インデックスパターン", "management.editIndexPattern.createIndex.defaultTypeName": "インデックスパターン", @@ -972,8 +970,8 @@ "kibana-react.savedObjects.saveModal.saveButtonLabel": "保存", "kibana-react.savedObjects.saveModal.saveTitle": "{objectType} を保存", "kibana-react.savedObjects.saveModal.titleLabel": "タイトル", - "kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", - "kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", "inspector.closeButton": "インスペクターを閉じる", "inspector.reqTimestampDescription": "リクエストの開始が記録された時刻です", "inspector.reqTimestampKey": "リクエストのタイムスタンプ", @@ -8050,7 +8048,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel": "異常スコア", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel": "下の境界", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel": "上の境界", - "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel": "値", "xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel": "{numberOfCauses} 個の {plusSign}異常な{byFieldName}値", "xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel": "複数バケットの影響", "xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel": "予定イベント {counter}", @@ -10968,7 +10965,6 @@ "xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel": "サーバー側エコシステム", "xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel": "ストレージクラス", "xpack.snapshotRestore.repositoryDetails.typeTitle": "レポジトリタイプ", - "xpack.snapshotRestore.repositoryDetails.verificationDetails": "認証情報レポジトリ「{name}」", "xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle": "詳細", "xpack.snapshotRestore.repositoryDetails.verificationTitle": "認証ステータス", "xpack.snapshotRestore.repositoryDetails.verifyButtonLabel": "レポジトリを検証", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 21ec06c6a191e..3cc476937d4e7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -441,9 +441,6 @@ "common.ui.flotCharts.tueLabel": "周二", "common.ui.flotCharts.wedLabel": "周三", "common.ui.management.breadcrumb": "管理", - "management.connectDataDisplayName": "连接数据", - "management.displayName": "管理", - "management.nav.menu": "管理菜单", "common.ui.modals.cancelButtonLabel": "取消", "common.ui.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "common.ui.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", @@ -522,6 +519,7 @@ "common.ui.aggTypes.buckets.ranges.rangesFormatMessage": "{gte} {from} 且 {lt} {to}", "management.connectDataDisplayName": "连接数据", "management.displayName": "管理", + "management.nav.menu": "管理菜单", "management.editIndexPattern.createIndex.defaultButtonDescription": "对任何数据执行完全聚合", "management.editIndexPattern.createIndex.defaultButtonText": "标准索引模式", "management.editIndexPattern.createIndex.defaultTypeName": "索引模式", @@ -973,8 +971,8 @@ "kibana-react.savedObjects.saveModal.saveButtonLabel": "保存", "kibana-react.savedObjects.saveModal.saveTitle": "保存 {objectType}", "kibana-react.savedObjects.saveModal.titleLabel": "标题", - "kibana_utils.stateManagement.url.unableToRestoreUrlErrorMessage": "无法完整还原 URL,确保使用共享功能。", - "kibana_utils.stateManagement.url.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", + "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "无法完整还原 URL,确保使用共享功能。", + "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,并且似乎没有任何可安全删除的项。\n\n通常可通过移至新的标签页来解决此问题,但这会导致更大的问题。如果您有规律地看到此消息,请在 {gitHubIssuesUrl} 提交问题。", "inspector.closeButton": "关闭检查器", "inspector.reqTimestampDescription": "记录请求启动的时间", "inspector.reqTimestampKey": "请求时间戳", @@ -8139,7 +8137,6 @@ "xpack.ml.timeSeriesExplorer.timeSeriesChart.anomalyScoreLabel": "异常分数", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.lowerBoundsLabel": "下边界", "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.upperBoundsLabel": "上边界", - "xpack.ml.timeSeriesExplorer.timeSeriesChart.modelPlotEnabled.valueLabel": "值", "xpack.ml.timeSeriesExplorer.timeSeriesChart.moreThanOneUnusualByFieldValuesLabel": "{numberOfCauses}{plusSign} 异常 {byFieldName} 值", "xpack.ml.timeSeriesExplorer.timeSeriesChart.multiBucketImpactLabel": "多存储桶影响", "xpack.ml.timeSeriesExplorer.timeSeriesChart.scheduledEventsLabel": "已计划事件{counter}", @@ -11057,7 +11054,6 @@ "xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel": "服务器端加密", "xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel": "存储类", "xpack.snapshotRestore.repositoryDetails.typeTitle": "存储库类型", - "xpack.snapshotRestore.repositoryDetails.verificationDetails": "验证详情存储库“{name}”", "xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle": "详情", "xpack.snapshotRestore.repositoryDetails.verificationTitle": "验证状态", "xpack.snapshotRestore.repositoryDetails.verifyButtonLabel": "验证存储库", diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 86db39823ba91..bda5b51623d05 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,6 +9,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), require.resolve('../test/reporting/configs/generate_api.js'), + require.resolve('../test/functional/config_security_basic.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), diff --git a/x-pack/test/api_integration/apis/endpoint/endpoints.ts b/x-pack/test/api_integration/apis/endpoint/endpoints.ts index 95c3678672da3..32864489d3786 100644 --- a/x-pack/test/api_integration/apis/endpoint/endpoints.ts +++ b/x-pack/test/api_integration/apis/endpoint/endpoints.ts @@ -10,9 +10,24 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); describe('test endpoints api', () => { - before(() => esArchiver.load('endpoint/endpoints')); - after(() => esArchiver.unload('endpoint/endpoints')); - describe('GET /api/endpoint/endpoints', () => { + describe('POST /api/endpoint/endpoints when index is empty', () => { + it('endpoints api should return empty result when index is empty', async () => { + await esArchiver.unload('endpoint/endpoints'); + const { body } = await supertest + .post('/api/endpoint/endpoints') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(0); + expect(body.endpoints.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_index).to.eql(0); + }); + }); + + describe('POST /api/endpoint/endpoints when index is not empty', () => { + before(() => esArchiver.load('endpoint/endpoints')); + after(() => esArchiver.unload('endpoint/endpoints')); it('endpoints api should return one entry for each endpoint with default paging', async () => { const { body } = await supertest .post('/api/endpoint/endpoints') @@ -46,6 +61,30 @@ export default function({ getService }: FtrProviderContext) { expect(body.request_index).to.eql(1); }); + /* test that when paging properties produces no result, the total should reflect the actual number of endpoints + in the index. + */ + it('endpoints api should return accurate total endpoints if page index produces no result', async () => { + const { body } = await supertest + .post('/api/endpoint/endpoints') + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 3, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(3); + expect(body.endpoints.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_index).to.eql(30); + }); + it('endpoints api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest .post('/api/endpoint/endpoints') diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json index 04d56d5949d2c..65094144d6ff0 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/index_detail.json @@ -9,21 +9,6 @@ "totalShards": 10, "status": "green" }, - "logs": { - "enabled": false, - "limit": 10, - "reason": { - "clusterExists": false, - "indexPatternExists": false, - "indexPatternInTimeRangeExists": false, - "typeExistsAtAnyTime": false, - "usingStructuredLogs": false, - "nodeExists": null, - "indexExists": false, - "typeExists": false - }, - "logs": [] - }, "metrics": { "index_search_request_rate": [ { @@ -1104,93 +1089,108 @@ } ] }, + "logs": { + "enabled": false, + "logs": [], + "reason": { + "indexPatternExists": false, + "indexPatternInTimeRangeExists": false, + "typeExistsAtAnyTime": false, + "typeExists": false, + "usingStructuredLogs": false, + "clusterExists": false, + "nodeExists": null, + "indexExists": false + }, + "limit": 10 + }, "shards": [ { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 4, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 4, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": true, "relocating_node": null, "shard": 1, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, "relocating_node": null, "shard": 1, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": true, "relocating_node": null, "shard": 2, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, "relocating_node": null, "shard": 2, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 3, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 3, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": false, + "index": "avocado-tweets-2017.10.02", "node": "xcP6ue7eRCieNNitFTT0EA", + "primary": false, "relocating_node": null, "shard": 0, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" }, { - "state": "STARTED", - "primary": true, + "index": "avocado-tweets-2017.10.02", "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, "relocating_node": null, "shard": 0, - "index": "avocado-tweets-2017.10.02" + "state": "STARTED" } ], "shardStats": { "nodes": { "jUT5KdxfRbORSCWkb5zjmA": { - "shardCount": 38, - "indexCount": 20, + "shardCount": 5, + "indexCount": 1, "name": "whatever-01", "node_ids": [ "jUT5KdxfRbORSCWkb5zjmA" @@ -1198,29 +1198,20 @@ "type": "master" }, "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, + "shardCount": 5, + "indexCount": 1, "name": "whatever-02", "node_ids": [ "xcP6ue7eRCieNNitFTT0EA" ], "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": [ - "bwQWH-7IQY-mFPpfoaoFXQ" - ], - "type": "node" } } }, "nodes": { "jUT5KdxfRbORSCWkb5zjmA": { - "shardCount": 38, - "indexCount": 20, + "shardCount": 5, + "indexCount": 1, "name": "whatever-01", "node_ids": [ "jUT5KdxfRbORSCWkb5zjmA" @@ -1228,22 +1219,13 @@ "type": "master" }, "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, + "shardCount": 5, + "indexCount": 1, "name": "whatever-02", "node_ids": [ "xcP6ue7eRCieNNitFTT0EA" ], "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": [ - "bwQWH-7IQY-mFPpfoaoFXQ" - ], - "type": "node" } }, "stateUuid": "6wwwErXyTfaa4uHBHG5Pbg" diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json index 0b8d26558e7fc..32096b0b97067 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/fixtures/node_detail.json @@ -1,293 +1,1518 @@ { "nodeSummary": { - "resolver": "jxcP6ue7eRCieNNitFTT0EA", - "node_ids": [], - "attributes": {}, - "transport_address": "", - "name": "jxcP6ue7eRCieNNitFTT0EA", - "type": "node", - "nodeTypeLabel": "Offline Node", - "status": "Offline", - "isOnline": false + "resolver": "jUT5KdxfRbORSCWkb5zjmA", + "node_ids": [ + "jUT5KdxfRbORSCWkb5zjmA" + ], + "attributes": { + "ml.enabled": "true", + "ml.max_open_jobs": "10" + }, + "transport_address": "127.0.0.1:9300", + "name": "whatever-01", + "type": "master", + "nodeTypeLabel": "Master Node", + "nodeTypeClass": "starFilled", + "totalShards": 38, + "indexCount": 20, + "documents": 24830, + "dataSize": 52847579, + "freeSpace": 186755088384, + "totalSpace": 499065712640, + "usedHeap": 29, + "status": "Online", + "isOnline": true + }, + "metrics": { + "node_total_io": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.operations", + "metricAgg": "max", + "label": "Total I/O", + "title": "I/O Operations Rate", + "description": "Total I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.read_operations", + "metricAgg": "max", + "label": "Total Read I/O", + "title": "I/O Operations Rate", + "description": "Total Read I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.fs.io_stats.total.write_operations", + "metricAgg": "max", + "label": "Total Write I/O", + "title": "I/O Operations Rate", + "description": "Total Write I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", + "units": "/s", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": true + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + null + ], + [ + 1507235540000, + null + ], + [ + 1507235550000, + null + ], + [ + 1507235560000, + null + ], + [ + 1507235570000, + null + ], + [ + 1507235580000, + null + ], + [ + 1507235590000, + null + ], + [ + 1507235600000, + null + ], + [ + 1507235610000, + null + ], + [ + 1507235620000, + null + ], + [ + 1507235630000, + null + ], + [ + 1507235640000, + null + ], + [ + 1507235650000, + null + ], + [ + 1507235660000, + null + ], + [ + 1507235670000, + null + ], + [ + 1507235680000, + null + ], + [ + 1507235690000, + null + ], + [ + 1507235700000, + null + ] + ] + } + ], + "node_latency": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.search.query_total", + "metricAgg": "sum", + "label": "Search", + "title": "Latency", + "description": "Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.", + "units": "ms", + "format": "0,0.[00]", + "hasCalculation": true, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + 0.33333333333333337 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 0.33333333333333337 + ], + [ + 1507235560000, + 0 + ], + [ + 1507235570000, + 0.33333333333333337 + ], + [ + 1507235580000, + 0 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0.33333333333333337 + ], + [ + 1507235610000, + 0 + ], + [ + 1507235620000, + 0 + ], + [ + 1507235630000, + 0.33333333333333337 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 0 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 0.2 + ], + [ + 1507235680000, + 0 + ], + [ + 1507235690000, + 0 + ], + [ + 1507235700000, + 0 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.indexing.index_total", + "metricAgg": "sum", + "label": "Indexing", + "title": "Latency", + "description": "Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This considers any shard located on this node, including replicas.", + "units": "ms", + "format": "0,0.[00]", + "hasCalculation": true, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + null + ], + [ + 1507235530000, + 0 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 0.888888888888889 + ], + [ + 1507235560000, + 1.1666666666666667 + ], + [ + 1507235570000, + 0 + ], + [ + 1507235580000, + 0 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0 + ], + [ + 1507235610000, + 1.3333333333333333 + ], + [ + 1507235620000, + 1.1666666666666667 + ], + [ + 1507235630000, + 0 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 0 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 2.3333333333333335 + ], + [ + 1507235680000, + 2.8749999999999996 + ], + [ + 1507235690000, + 0 + ], + [ + 1507235700000, + 0 + ] + ] + } + ], + "node_jvm_mem": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.jvm.mem.heap_max_in_bytes", + "metricAgg": "max", + "label": "Max Heap", + "title": "JVM Heap", + "description": "Total heap available to Elasticsearch running in the JVM.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 709623808 + ], + [ + 1507235530000, + 709623808 + ], + [ + 1507235540000, + 709623808 + ], + [ + 1507235550000, + 709623808 + ], + [ + 1507235560000, + 709623808 + ], + [ + 1507235570000, + 709623808 + ], + [ + 1507235580000, + 709623808 + ], + [ + 1507235590000, + 709623808 + ], + [ + 1507235600000, + 709623808 + ], + [ + 1507235610000, + 709623808 + ], + [ + 1507235620000, + 709623808 + ], + [ + 1507235630000, + 709623808 + ], + [ + 1507235640000, + 709623808 + ], + [ + 1507235650000, + 709623808 + ], + [ + 1507235660000, + 709623808 + ], + [ + 1507235670000, + 709623808 + ], + [ + 1507235680000, + 709623808 + ], + [ + 1507235690000, + 709623808 + ], + [ + 1507235700000, + 709623808 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.jvm.mem.heap_used_in_bytes", + "metricAgg": "max", + "label": "Used Heap", + "title": "JVM Heap", + "description": "Total heap used by Elasticsearch running in the JVM.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 317052776 + ], + [ + 1507235530000, + 344014976 + ], + [ + 1507235540000, + 368593248 + ], + [ + 1507235550000, + 253850400 + ], + [ + 1507235560000, + 348095032 + ], + [ + 1507235570000, + 182919712 + ], + [ + 1507235580000, + 212395016 + ], + [ + 1507235590000, + 244004144 + ], + [ + 1507235600000, + 270412240 + ], + [ + 1507235610000, + 245052864 + ], + [ + 1507235620000, + 370270616 + ], + [ + 1507235630000, + 196944168 + ], + [ + 1507235640000, + 223491760 + ], + [ + 1507235650000, + 253878472 + ], + [ + 1507235660000, + 280811736 + ], + [ + 1507235670000, + 371931976 + ], + [ + 1507235680000, + 329874616 + ], + [ + 1507235690000, + 363869776 + ], + [ + 1507235700000, + 211045968 + ] + ] + } + ], + "node_mem": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.memory_in_bytes", + "metricAgg": "max", + "label": "Lucene Total", + "title": "Index Memory", + "description": "Total heap memory used by Lucene for current index. This is the sum of other fields for primary and replica shards on this node.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 4797457 + ], + [ + 1507235530000, + 4797457 + ], + [ + 1507235540000, + 4797457 + ], + [ + 1507235550000, + 4797457 + ], + [ + 1507235560000, + 4823580 + ], + [ + 1507235570000, + 4823580 + ], + [ + 1507235580000, + 4823580 + ], + [ + 1507235590000, + 4823580 + ], + [ + 1507235600000, + 4823580 + ], + [ + 1507235610000, + 4838368 + ], + [ + 1507235620000, + 4741420 + ], + [ + 1507235630000, + 4741420 + ], + [ + 1507235640000, + 4741420 + ], + [ + 1507235650000, + 4741420 + ], + [ + 1507235660000, + 4741420 + ], + [ + 1507235670000, + 4757998 + ], + [ + 1507235680000, + 4787542 + ], + [ + 1507235690000, + 4787542 + ], + [ + 1507235700000, + 4787542 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.terms_memory_in_bytes", + "metricAgg": "max", + "label": "Terms", + "title": "Index Memory", + "description": "Heap memory used by Terms (e.g., text). This is a part of Lucene Total.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 3764438 + ], + [ + 1507235530000, + 3764438 + ], + [ + 1507235540000, + 3764438 + ], + [ + 1507235550000, + 3764438 + ], + [ + 1507235560000, + 3786762 + ], + [ + 1507235570000, + 3786762 + ], + [ + 1507235580000, + 3786762 + ], + [ + 1507235590000, + 3786762 + ], + [ + 1507235600000, + 3786762 + ], + [ + 1507235610000, + 3799306 + ], + [ + 1507235620000, + 3715996 + ], + [ + 1507235630000, + 3715996 + ], + [ + 1507235640000, + 3715996 + ], + [ + 1507235650000, + 3715996 + ], + [ + 1507235660000, + 3715996 + ], + [ + 1507235670000, + 3729890 + ], + [ + 1507235680000, + 3755528 + ], + [ + 1507235690000, + 3755528 + ], + [ + 1507235700000, + 3755528 + ] + ] + }, + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.points_memory_in_bytes", + "metricAgg": "max", + "label": "Points", + "title": "Index Memory", + "description": "Heap memory used by Points (e.g., numbers, IPs, and geo data). This is a part of Lucene Total.", + "units": "B", + "format": "0.0 b", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 12171 + ], + [ + 1507235530000, + 12171 + ], + [ + 1507235540000, + 12171 + ], + [ + 1507235550000, + 12171 + ], + [ + 1507235560000, + 12198 + ], + [ + 1507235570000, + 12198 + ], + [ + 1507235580000, + 12198 + ], + [ + 1507235590000, + 12198 + ], + [ + 1507235600000, + 12198 + ], + [ + 1507235610000, + 12218 + ], + [ + 1507235620000, + 12120 + ], + [ + 1507235630000, + 12120 + ], + [ + 1507235640000, + 12120 + ], + [ + 1507235650000, + 12120 + ], + [ + 1507235660000, + 12120 + ], + [ + 1507235670000, + 12140 + ], + [ + 1507235680000, + 12166 + ], + [ + 1507235690000, + 12166 + ], + [ + 1507235700000, + 12166 + ] + ] + } + ], + "node_cpu_metric": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.process.cpu.percent", + "metricAgg": "max", + "label": "CPU Utilization", + "description": "Percentage of CPU usage for the Elasticsearch process.", + "units": "%", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 1 + ], + [ + 1507235530000, + 0 + ], + [ + 1507235540000, + 0 + ], + [ + 1507235550000, + 1 + ], + [ + 1507235560000, + 2 + ], + [ + 1507235570000, + 0 + ], + [ + 1507235580000, + 2 + ], + [ + 1507235590000, + 0 + ], + [ + 1507235600000, + 0 + ], + [ + 1507235610000, + 3 + ], + [ + 1507235620000, + 2 + ], + [ + 1507235630000, + 2 + ], + [ + 1507235640000, + 0 + ], + [ + 1507235650000, + 1 + ], + [ + 1507235660000, + 0 + ], + [ + 1507235670000, + 2 + ], + [ + 1507235680000, + 2 + ], + [ + 1507235690000, + 1 + ], + [ + 1507235700000, + 0 + ] + ] + } + ], + "node_load_average": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.os.cpu.load_average.1m", + "metricAgg": "max", + "label": "1m", + "title": "System Load", + "description": "Load average over the last minute.", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 2.876953125 + ], + [ + 1507235530000, + 2.66015625 + ], + [ + 1507235540000, + 2.40625 + ], + [ + 1507235550000, + 2.189453125 + ], + [ + 1507235560000, + 2.626953125 + ], + [ + 1507235570000, + 2.451171875 + ], + [ + 1507235580000, + 2.81640625 + ], + [ + 1507235590000, + 3.70703125 + ], + [ + 1507235600000, + 3.51171875 + ], + [ + 1507235610000, + 3.359375 + ], + [ + 1507235620000, + 3.076171875 + ], + [ + 1507235630000, + 2.990234375 + ], + [ + 1507235640000, + 2.904296875 + ], + [ + 1507235650000, + 2.84375 + ], + [ + 1507235660000, + 3.28125 + ], + [ + 1507235670000, + 5.30859375 + ], + [ + 1507235680000, + 7.63671875 + ], + [ + 1507235690000, + 9.4375 + ], + [ + 1507235700000, + 11.421875 + ] + ] + } + ], + "node_segment_count": [ + { + "bucket_size": "10 seconds", + "timeRange": { + "min": 1507235508000, + "max": 1507235712000 + }, + "metric": { + "app": "elasticsearch", + "field": "node_stats.indices.segments.count", + "metricAgg": "max", + "label": "Segment Count", + "description": "Maximum segment count for primary and replica shards on this node.", + "units": "", + "format": "0,0.[00]", + "hasCalculation": false, + "isDerivative": false + }, + "data": [ + [ + 1507235520000, + 128 + ], + [ + 1507235530000, + 128 + ], + [ + 1507235540000, + 128 + ], + [ + 1507235550000, + 128 + ], + [ + 1507235560000, + 131 + ], + [ + 1507235570000, + 131 + ], + [ + 1507235580000, + 131 + ], + [ + 1507235590000, + 131 + ], + [ + 1507235600000, + 131 + ], + [ + 1507235610000, + 133 + ], + [ + 1507235620000, + 126 + ], + [ + 1507235630000, + 126 + ], + [ + 1507235640000, + 126 + ], + [ + 1507235650000, + 126 + ], + [ + 1507235660000, + 126 + ], + [ + 1507235670000, + 128 + ], + [ + 1507235680000, + 130 + ], + [ + 1507235690000, + 130 + ], + [ + 1507235700000, + 130 + ] + ] + } + ] }, "logs": { "enabled": false, - "limit": 10, + "logs": [], "reason": { - "clusterExists": false, "indexPatternExists": false, "indexPatternInTimeRangeExists": false, "typeExistsAtAnyTime": false, + "typeExists": false, "usingStructuredLogs": false, + "clusterExists": false, "nodeExists": false, - "indexExists": null, - "typeExists": false + "indexExists": null }, - "logs": [] + "limit": 10 }, - "metrics": { - "node_latency": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.search.query_total", - "metricAgg": "sum", - "label": "Search", - "title": "Latency", - "description": "Average latency for searching, which is time it takes to execute searches divided by number of searches submitted. This considers primary and replica shards.", - "units": "ms", - "format": "0,0.[00]", - "hasCalculation": true, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.indexing.index_total", - "metricAgg": "sum", - "label": "Indexing", - "title": "Latency", - "description": "Average latency for indexing documents, which is time it takes to index documents divided by number that were indexed. This considers any shard located on this node, including replicas.", - "units": "ms", - "format": "0,0.[00]", - "hasCalculation": true, - "isDerivative": false - }, - "data": [] - }], - "node_jvm_mem": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.jvm.mem.heap_max_in_bytes", - "metricAgg": "max", - "label": "Max Heap", - "title": "JVM Heap", - "description": "Total heap available to Elasticsearch running in the JVM.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.jvm.mem.heap_used_in_bytes", - "metricAgg": "max", - "label": "Used Heap", - "title": "JVM Heap", - "description": "Total heap used by Elasticsearch running in the JVM.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_mem": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.memory_in_bytes", - "metricAgg": "max", - "label": "Lucene Total", - "title": "Index Memory", - "description": "Total heap memory used by Lucene for current index. This is the sum of other fields for primary and replica shards on this node.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.terms_memory_in_bytes", - "metricAgg": "max", - "label": "Terms", - "title": "Index Memory", - "description": "Heap memory used by Terms (e.g., text). This is a part of Lucene Total.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }, { - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.points_memory_in_bytes", - "metricAgg": "max", - "label": "Points", - "title": "Index Memory", - "description": "Heap memory used by Points (e.g., numbers, IPs, and geo data). This is a part of Lucene Total.", - "units": "B", - "format": "0.0 b", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_cpu_metric": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.process.cpu.percent", - "metricAgg": "max", - "label": "CPU Utilization", - "description": "Percentage of CPU usage for the Elasticsearch process.", - "units": "%", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_total_io": [{ - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } - }, - { - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total Read I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.read_operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total Read I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } + "shards": [ + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" }, { - "bucket_size": "10 seconds", - "data": [], - "metric": { - "app": "elasticsearch", - "description": "Total Write I/O. (This metric is not supported on all platforms and may display N/A if I/O data is unavailable.)", - "field": "node_stats.fs.io_stats.total.write_operations", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": true, - "label": "Total Write I/O", - "metricAgg": "max", - "title": "I/O Operations Rate", - "units": "/s" - }, - "timeRange": { - "max": 1507235712000, - "min": 1507235508000 - } - }], - "node_load_average": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.os.cpu.load_average.1m", - "metricAgg": "max", - "label": "1m", - "title": "System Load", - "description": "Load average over the last minute.", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }], - "node_segment_count": [{ - "bucket_size": "10 seconds", - "timeRange": { - "min": 1507235508000, - "max": 1507235712000 - }, - "metric": { - "app": "elasticsearch", - "field": "node_stats.indices.segments.count", - "metricAgg": "max", - "label": "Segment Count", - "description": "Maximum segment count for primary and replica shards on this node.", - "units": "", - "format": "0,0.[00]", - "hasCalculation": false, - "isDerivative": false - }, - "data": [] - }] - }, - "shards": [], + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "watermelon-tweets-2017.10.05", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.09.30", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.03", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "phone-home", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 4, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 1, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": false, + "relocating_node": null, + "shard": 2, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 3, + "state": "STARTED" + }, + { + "index": "avocado-tweets-2017.10.02", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": null, + "shard": 0, + "state": "STARTED" + }, + { + "index": "relocation_test", + "node": "jUT5KdxfRbORSCWkb5zjmA", + "primary": true, + "relocating_node": "bwQWH-7IQY-mFPpfoaoFXQ", + "shard": 0, + "state": "RELOCATING" + } + ], "shardStats": { "indices": { "avocado-tweets-2017.09.30": { "status": "green", - "primary": 5, - "replica": 5, + "primary": 3, + "replica": 2, "unassigned": { "primary": 0, "replica": 0 @@ -295,8 +1520,8 @@ }, "avocado-tweets-2017.10.02": { "status": "green", - "primary": 5, - "replica": 5, + "primary": 3, + "replica": 2, "unassigned": { "primary": 0, "replica": 0 @@ -305,43 +1530,34 @@ "avocado-tweets-2017.10.03": { "status": "green", "primary": 5, - "replica": 5, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 } }, "phone-home": { - "status": "yellow", - "primary": 5, - "replica": 4, + "status": "green", + "primary": 4, + "replica": 0, "unassigned": { "primary": 0, - "replica": 1 + "replica": 0 } }, "watermelon-tweets-2017.10.05": { - "status": "yellow", - "primary": 5, - "replica": 4, - "unassigned": { - "primary": 0, - "replica": 1 - } - }, - ".security-v6": { - "status": "yellow", - "primary": 1, - "replica": 1, + "status": "green", + "primary": 4, + "replica": 0, "unassigned": { "primary": 0, - "replica": 1 + "replica": 0 } }, ".kibana": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -349,7 +1565,7 @@ }, ".monitoring-alerts-6": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -358,7 +1574,7 @@ }, ".monitoring-es-6-2017.10.05": { "status": "yellow", - "primary": 1, + "primary": 0, "replica": 0, "unassigned": { "primary": 0, @@ -367,17 +1583,26 @@ }, ".monitoring-kibana-6-2017.10.05": { "status": "yellow", - "primary": 1, + "primary": 0, "replica": 0, "unassigned": { "primary": 0, "replica": 1 } }, + ".security-v6": { + "status": "green", + "primary": 1, + "replica": 0, + "unassigned": { + "primary": 0, + "replica": 0 + } + }, ".triggered_watches": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -386,7 +1611,7 @@ ".watcher-history-7-2017.09.29": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -395,7 +1620,7 @@ ".watcher-history-7-2017.09.30": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -404,7 +1629,7 @@ ".watcher-history-7-2017.10.01": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -412,7 +1637,7 @@ }, ".watcher-history-7-2017.10.02": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -422,7 +1647,7 @@ ".watcher-history-7-2017.10.03": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -430,7 +1655,7 @@ }, ".watcher-history-7-2017.10.04": { "status": "green", - "primary": 1, + "primary": 0, "replica": 1, "unassigned": { "primary": 0, @@ -440,7 +1665,7 @@ ".watcher-history-7-2017.10.05": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -449,7 +1674,7 @@ ".watches": { "status": "green", "primary": 1, - "replica": 1, + "replica": 0, "unassigned": { "primary": 0, "replica": 0 @@ -471,22 +1696,10 @@ "shardCount": 38, "indexCount": 20, "name": "whatever-01", - "node_ids": ["jUT5KdxfRbORSCWkb5zjmA"], + "node_ids": [ + "jUT5KdxfRbORSCWkb5zjmA" + ], "type": "master" - }, - "xcP6ue7eRCieNNitFTT0EA": { - "shardCount": 36, - "indexCount": 19, - "name": "whatever-02", - "node_ids": ["xcP6ue7eRCieNNitFTT0EA"], - "type": "node" - }, - "bwQWH-7IQY-mFPpfoaoFXQ": { - "shardCount": 4, - "indexCount": 4, - "name": "whatever-03", - "node_ids": ["bwQWH-7IQY-mFPpfoaoFXQ"], - "type": "node" } }, "stateUuid": "6wwwErXyTfaa4uHBHG5Pbg" diff --git a/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js b/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js index 43b5f6d119d6b..9fbf1e02c9c2e 100644 --- a/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js +++ b/x-pack/test/api_integration/apis/monitoring/elasticsearch/node_detail.js @@ -32,7 +32,7 @@ export default function({ getService }) { it('should summarize node with metrics', async () => { const { body } = await supertest .post( - '/api/monitoring/v1/clusters/YCxj-RAgSZCP6GuOQ8M1EQ/elasticsearch/nodes/jxcP6ue7eRCieNNitFTT0EA' + '/api/monitoring/v1/clusters/YCxj-RAgSZCP6GuOQ8M1EQ/elasticsearch/nodes/jUT5KdxfRbORSCWkb5zjmA' ) .set('kbn-xsrf', 'xxx') .send({ diff --git a/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js b/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js deleted file mode 100644 index c21a602d7ba1a..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/filter_bar.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { expectFixtureEql } from './helpers/expect_fixture_eql'; -import { filterBarQueryString } from '../../../../../legacy/plugins/uptime/public/queries'; - -export default function({ getService }) { - describe('filterBar query', () => { - before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat')); - after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat')); - - const supertest = getService('supertest'); - - it('returns the expected filters', async () => { - const getFilterBarQuery = { - operationName: 'FilterBar', - query: filterBarQueryString, - variables: { - dateRangeStart: '2019-01-28T17:40:08.078Z', - dateRangeEnd: '2025-01-28T19:00:16.078Z', - }, - }; - const { - body: { data }, - } = await supertest - .post('/api/uptime/graphql') - .set('kbn-xsrf', 'foo') - .send({ ...getFilterBarQuery }); - expectFixtureEql(data, 'filter_list'); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json deleted file mode 100644 index f07e416322105..0000000000000 --- a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filter_list.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "filterBar": { - "ids": [ - "0000-intermittent", - "0001-up", - "0002-up", - "0003-up", - "0004-up", - "0005-up", - "0006-up", - "0007-up", - "0008-up", - "0009-up", - "0010-down", - "0011-up", - "0012-up", - "0013-up", - "0014-up", - "0015-intermittent", - "0016-up", - "0017-up", - "0018-up", - "0019-up" - ], - "locations": [ - "mpls" - ], - "ports": [ - 5678 - ], - "schemes": [ - "http" - ], - "urls": [ - "http://localhost:5678/pattern?r=200x1", - "http://localhost:5678/pattern?r=200x5,500x1", - "http://localhost:5678/pattern?r=400x1" - ] - } -} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json new file mode 100644 index 0000000000000..76e307f27d841 --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/graphql/fixtures/filters.json @@ -0,0 +1,12 @@ +{ + "schemes": [ + "http" + ], + "ports": [ + 5678 + ], + "locations": [ + "mpls" + ], + "tags": [] +} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/uptime/graphql/index.js b/x-pack/test/api_integration/apis/uptime/graphql/index.js index 5c93be83ab7d9..64999761fde4e 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/index.js +++ b/x-pack/test/api_integration/apis/uptime/graphql/index.js @@ -11,7 +11,6 @@ export default function({ loadTestFile }) { // verifying the pre-loaded documents are returned in a way that // matches the snapshots contained in './fixtures' loadTestFile(require.resolve('./doc_count')); - loadTestFile(require.resolve('./filter_bar')); loadTestFile(require.resolve('./monitor_charts')); loadTestFile(require.resolve('./monitor_states')); loadTestFile(require.resolve('./ping_list')); diff --git a/x-pack/test/api_integration/apis/uptime/rest/filters.ts b/x-pack/test/api_integration/apis/uptime/rest/filters.ts new file mode 100644 index 0000000000000..6cec6143a6d7c --- /dev/null +++ b/x-pack/test/api_integration/apis/uptime/rest/filters.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const getApiPath = (dateRangeStart: string, dateRangeEnd: string, filters?: string) => + `/api/uptime/filters?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}${ + filters ? `&filters=${filters}` : '' + }`; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('filter group endpoint', () => { + const dateRangeStart = '2019-01-28T17:40:08.078Z'; + const dateRangeEnd = '2025-01-28T19:00:16.078Z'; + + it('returns expected filters', async () => { + const resp = await supertest.get(getApiPath(dateRangeStart, dateRangeEnd)); + expectFixtureEql(resp.body, 'filters'); + }); + }); +} diff --git a/x-pack/test/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 f148d62421ff8..ad4f81777e780 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 9ac6d4fdef19f..ee58be76928b3 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 9190e0b4886ce..e2d5efac4644c 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 191ba5c4d1e25..1ac1784e0e05d 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('APM'); }); @@ -61,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts index a58eb61ec4ca2..d0e37ec8e3f35 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 5a6857901536f..28b572401892b 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Canvas'); }); @@ -98,9 +96,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Canvas'); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index aa6860b35763f..d25fae3c4894c 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 c1197fa7023c5..ebe08a60c2563 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts @@ -43,9 +43,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dashboard'); }); @@ -107,9 +105,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dashboard'); }); diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts index fd7739e6930d0..494fd71ea6f34 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 e3bc3a1c6ce11..4184d223a9686 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 553ce459ebb18..1912b16d96f36 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 3e5dcd7b0c987..e6b6f28f8b92f 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 1d1fb566eb075..d8eb969b99b3b 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 acc8943033a1a..a2b062e6ef84f 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 0945b35ba0930..a0b0d5bef9668 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 4929bb52c170c..30cdc95b38e62 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 bc8542288410c..6a2b77de17f45 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 4d61e0996419c..5062f094061c0 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 300b22e5bcbc3..7c2a11a542d66 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 d092e6736656e..b9634c29dac1c 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 8230b25efbbf9..6b078d2cfa71a 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 8b2df502dc100..8fb6f21c778d3 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 13036737218bc..fc94688e98811 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 cf31f445a96f3..804ad5725edfd 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 0c86b47b373e6..e157586aecead 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 8848df83d36d6..d985da42ab5ed 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 80f33ff6175c5..7459b53ca4a32 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts @@ -41,9 +41,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); @@ -74,9 +72,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); diff --git a/x-pack/test/functional/apps/security/basic_license/index.ts b/x-pack/test/functional/apps/security/basic_license/index.ts new file mode 100644 index 0000000000000..0dbbd3988f8dd --- /dev/null +++ b/x-pack/test/functional/apps/security/basic_license/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security app - basic license', function() { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./role_mappings')); + }); +} diff --git a/x-pack/test/functional/apps/security/basic_license/role_mappings.ts b/x-pack/test/functional/apps/security/basic_license/role_mappings.ts new file mode 100644 index 0000000000000..45b325d57bee0 --- /dev/null +++ b/x-pack/test/functional/apps/security/basic_license/role_mappings.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'roleMappings']); + const testSubjects = getService('testSubjects'); + + describe('Role Mappings', function() { + before(async () => { + await pageObjects.common.navigateToApp('settings'); + }); + + it('does not render the Role Mappings UI under the basic license', async () => { + await testSubjects.missingOrFail('roleMappings'); + }); + }); +}; diff --git a/x-pack/test/functional/apps/security/index.js b/x-pack/test/functional/apps/security/index.js index b5d9b5f14be97..827a702b92d85 100644 --- a/x-pack/test/functional/apps/security/index.js +++ b/x-pack/test/functional/apps/security/index.js @@ -16,5 +16,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./field_level_security')); loadTestFile(require.resolve('./rbac_phase1')); loadTestFile(require.resolve('./user_email')); + loadTestFile(require.resolve('./role_mappings')); }); } diff --git a/x-pack/test/functional/apps/security/role_mappings.ts b/x-pack/test/functional/apps/security/role_mappings.ts new file mode 100644 index 0000000000000..5fed56ee79e3d --- /dev/null +++ b/x-pack/test/functional/apps/security/role_mappings.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { parse } from 'url'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'roleMappings']); + const security = getService('security'); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const aceEditor = getService('aceEditor'); + + describe('Role Mappings', function() { + before(async () => { + await pageObjects.common.navigateToApp('roleMappings'); + }); + + it('displays a message when no role mappings exist', async () => { + await testSubjects.existOrFail('roleMappingsEmptyPrompt'); + await testSubjects.existOrFail('createRoleMappingButton'); + }); + + it('allows a role mapping to be created', async () => { + await testSubjects.click('createRoleMappingButton'); + await testSubjects.setValue('roleMappingFormNameInput', 'new_role_mapping'); + await testSubjects.setValue('roleMappingFormRoleComboBox', 'superuser'); + await browser.pressKeys(browser.keys.ENTER); + + await testSubjects.click('roleMappingsAddRuleButton'); + + await testSubjects.click('roleMappingsJSONRuleEditorButton'); + + await aceEditor.setValue( + 'roleMappingsJSONEditor', + JSON.stringify({ + all: [ + { + field: { + username: '*', + }, + }, + { + field: { + 'metadata.foo.bar': 'baz', + }, + }, + { + except: { + any: [ + { + field: { + dn: 'foo', + }, + }, + { + field: { + dn: 'bar', + }, + }, + ], + }, + }, + ], + }) + ); + + await testSubjects.click('roleMappingsVisualRuleEditorButton'); + + await testSubjects.click('saveRoleMappingButton'); + + await testSubjects.existOrFail('savedRoleMappingSuccessToast'); + }); + + it('allows a role mapping to be deleted', async () => { + await testSubjects.click(`deleteRoleMappingButton-new_role_mapping`); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.existOrFail('deletedRoleMappingSuccessToast'); + }); + + it('displays an error and returns to the listing page when navigating to a role mapping which does not exist', async () => { + await pageObjects.common.navigateToActualUrl( + 'kibana', + '#/management/security/role_mappings/edit/i-do-not-exist', + { ensureCurrentUrl: false } + ); + + await testSubjects.existOrFail('errorLoadingRoleMappingEditorToast'); + + const url = parse(await browser.getCurrentUrl()); + + expect(url.hash).to.eql('#/management/security/role_mappings?_g=()'); + }); + + describe('with role mappings', () => { + const mappings = [ + { + name: 'a_enabled_role_mapping', + enabled: true, + roles: ['superuser'], + rules: { + field: { + username: '*', + }, + }, + metadata: {}, + }, + { + name: 'b_disabled_role_mapping', + enabled: false, + role_templates: [{ template: { source: 'superuser' } }], + rules: { + field: { + username: '*', + }, + }, + metadata: {}, + }, + ]; + + before(async () => { + await Promise.all( + mappings.map(mapping => { + const { name, ...payload } = mapping; + return security.roleMappings.create(name, payload); + }) + ); + + await pageObjects.common.navigateToApp('roleMappings'); + }); + + after(async () => { + await Promise.all(mappings.map(mapping => security.roleMappings.delete(mapping.name))); + }); + + it('displays a table of all role mappings', async () => { + const rows = await testSubjects.findAll('roleMappingRow'); + expect(rows.length).to.eql(mappings.length); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const mapping = mappings[i]; + + const name = await ( + await testSubjects.findDescendant('roleMappingName', row) + ).getVisibleText(); + + const enabled = + (await ( + await testSubjects.findDescendant('roleMappingEnabled', row) + ).getVisibleText()) === 'Enabled'; + + expect(name).to.eql(mapping.name); + expect(enabled).to.eql(mapping.enabled); + } + }); + + it('allows a role mapping to be edited', async () => { + await testSubjects.click('roleMappingName'); + await testSubjects.click('saveRoleMappingButton'); + await testSubjects.existOrFail('savedRoleMappingSuccessToast'); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/snapshot_restore/home_page.ts b/x-pack/test/functional/apps/snapshot_restore/home_page.ts index 99d3ea7834e6b..608c7f321a08f 100644 --- a/x-pack/test/functional/apps/snapshot_restore/home_page.ts +++ b/x-pack/test/functional/apps/snapshot_restore/home_page.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'snapshotRestore']); const log = getService('log'); + const es = getService('legacyEs'); describe('Home page', function() { this.tags('smoke'); @@ -26,5 +27,37 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const repositoriesButton = await pageObjects.snapshotRestore.registerRepositoryButton(); expect(await repositoriesButton.isDisplayed()).to.be(true); }); + + describe('Repositories Tab', async () => { + before(async () => { + await es.snapshot.createRepository({ + repository: 'my-repository', + body: { + type: 'fs', + settings: { + location: '/tmp/es-backups/', + compress: true, + }, + }, + verify: true, + }); + await pageObjects.snapshotRestore.navToRepositories(); + }); + + it('cleanup repository', async () => { + await pageObjects.snapshotRestore.viewRepositoryDetails('my-repository'); + await pageObjects.common.sleep(25000); + const cleanupResponse = await pageObjects.snapshotRestore.performRepositoryCleanup(); + await pageObjects.common.sleep(25000); + expect(cleanupResponse).to.contain('results'); + expect(cleanupResponse).to.contain('deleted_bytes'); + expect(cleanupResponse).to.contain('deleted_blobs'); + }); + after(async () => { + await es.snapshot.deleteRepository({ + repository: 'my-repository', + }); + }); + }); }); }; diff --git a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts index 46f0be1e6f6d6..1e79c76bf83e5 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 64fb218a62c80..dea45f161e451 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 ea5e255071dad..fb203a23359bd 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 c5a597cdaffb0..a004f8db66823 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 96bc3c5f74f59..77c5b323340bf 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Uptime'); }); @@ -59,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 0366d12808370..bcfb72967b75a 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -29,6 +29,27 @@ export default ({ getPageObjects }: FtrProviderContext) => { await pageObjects.uptime.pageHasExpectedIds(['0000-intermittent']); }); + it('applies filters for multiple fields', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.selectFilterItems({ + location: ['mpls'], + port: ['5678'], + scheme: ['http'], + }); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + ]); + }); + it('pagination is cleared when filter criteria changes', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await pageObjects.uptime.changePage('next'); 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 86fe606ecafad..d55076cb0ab43 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 d0fdc7c95ea38..9193862d2ba9e 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Visualize'); }); @@ -81,9 +79,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Visualize'); }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 17235c61c7d8c..664bfdf8d2a74 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -69,7 +69,7 @@ export default async function({ readConfigFile }) { esTestCluster: { license: 'trial', from: 'snapshot', - serverArgs: [], + serverArgs: ['path.repo=/tmp/'], }, kbnTestServer: { @@ -160,6 +160,10 @@ export default async function({ readConfigFile }) { ml: { pathname: '/app/ml', }, + roleMappings: { + pathname: '/app/kibana', + hash: '/management/security/role_mappings', + }, rollupJob: { pathname: '/app/kibana', hash: '/management/elasticsearch/rollup_jobs/', diff --git a/x-pack/test/functional/config_security_basic.js b/x-pack/test/functional/config_security_basic.js new file mode 100644 index 0000000000000..12d94e922a97c --- /dev/null +++ b/x-pack/test/functional/config_security_basic.js @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable import/no-default-export */ + +import { resolve } from 'path'; + +import { services } from './services'; +import { pageObjects } from './page_objects'; + +// the default export of config files must be a config provider +// that returns an object with the projects config values +export default async function({ readConfigFile }) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + return { + // list paths to the files that contain your plugins tests + testFiles: [resolve(__dirname, './apps/security/basic_license')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'basic', + from: 'snapshot', + serverArgs: [], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--telemetry.banner=false', + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + }, + }, + // the apps section defines the urls that + // `PageObjects.common.navigateTo(appKey)` will use. + // Merge urls for your plugin with the urls defined in + // Kibana's config in order to use this helper + apps: { + ...kibanaFunctionalConfig.get('apps'), + }, + + // choose where esArchiver should load archives from + esArchiver: { + directory: resolve(__dirname, 'es_archives'), + }, + + // choose where screenshots should be saved + screenshots: { + directory: resolve(__dirname, 'screenshots'), + }, + + junit: { + reportName: 'Chrome X-Pack UI Functional Tests', + }, + }; +} diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 82011c48d4460..18ea515a73147 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -46,6 +46,7 @@ import { RemoteClustersPageProvider } from './remote_clusters_page'; import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page'; import { LensPageProvider } from './lens_page'; import { InfraMetricExplorerProvider } from './infra_metric_explorer'; +import { RoleMappingsPageProvider } from './role_mappings_page'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -78,4 +79,5 @@ export const pageObjects = { remoteClusters: RemoteClustersPageProvider, copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider, lens: LensPageProvider, + roleMappings: RoleMappingsPageProvider, }; diff --git a/x-pack/test/functional/page_objects/role_mappings_page.ts b/x-pack/test/functional/page_objects/role_mappings_page.ts new file mode 100644 index 0000000000000..b1adfb00af739 --- /dev/null +++ b/x-pack/test/functional/page_objects/role_mappings_page.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function RoleMappingsPageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + async appTitleText() { + return await testSubjects.getVisibleText('appTitle'); + }, + }; +} diff --git a/x-pack/test/functional/page_objects/snapshot_restore_page.ts b/x-pack/test/functional/page_objects/snapshot_restore_page.ts index 25bdfc7075727..1c8ba9f633111 100644 --- a/x-pack/test/functional/page_objects/snapshot_restore_page.ts +++ b/x-pack/test/functional/page_objects/snapshot_restore_page.ts @@ -3,11 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { FtrProviderContext } from '../ftr_provider_context'; export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { async appTitleText() { @@ -16,5 +16,50 @@ export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) async registerRepositoryButton() { return await testSubjects.find('registerRepositoryButton'); }, + async navToRepositories() { + await testSubjects.click('repositories_tab'); + await retry.waitForWithTimeout( + 'Wait for register repository button to be on page', + 10000, + async () => { + return await testSubjects.isDisplayed('registerRepositoryButton'); + } + ); + }, + async getRepoList() { + const table = await testSubjects.find('repositoryTable'); + const rows = await table.findAllByCssSelector('[data-test-subj="row"]'); + return await Promise.all( + rows.map(async row => { + return { + repoName: await ( + await row.findByCssSelector('[data-test-subj="Name_cell"]') + ).getVisibleText(), + repoLink: await ( + await row.findByCssSelector('[data-test-subj="Name_cell"]') + ).findByCssSelector('a'), + repoType: await ( + await row.findByCssSelector('[data-test-subj="Type_cell"]') + ).getVisibleText(), + repoEdit: await row.findByCssSelector('[data-test-subj="editRepositoryButton"]'), + repoDelete: await row.findByCssSelector('[data-test-subj="deleteRepositoryButton"]'), + }; + }) + ); + }, + async viewRepositoryDetails(name: string) { + const repos = await this.getRepoList(); + if (repos.length === 1) { + const repoToView = repos.filter(r => (r.repoName = name))[0]; + await repoToView.repoLink.click(); + } + await retry.waitForWithTimeout(`Repo title should be ${name}`, 10000, async () => { + return (await testSubjects.getVisibleText('title')) === name; + }); + }, + async performRepositoryCleanup() { + await testSubjects.click('cleanupRepositoryButton'); + return await testSubjects.getVisibleText('cleanupCodeBlock'); + }, }; } diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index 6018b7ceebfa5..f04f96148583f 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -71,6 +71,17 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo } } + public async selectFilterItems(filters: Record) { + for (const key in filters) { + if (filters.hasOwnProperty(key)) { + const values = filters[key]; + for (let i = 0; i < values.length; i++) { + await uptimeService.selectFilterItem(key, values[i]); + } + } + } + } + public async getSnapshotCount() { return await uptimeService.getSnapshotCount(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 12f1a23f1e024..ca38c2e9dd897 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -49,6 +49,15 @@ export function UptimeProvider({ getService }: FtrProviderContext) { async setStatusFilterDown() { await testSubjects.click('xpack.uptime.filterBar.filterStatusDown'); }, + async selectFilterItem(filterType: string, option: string) { + const popoverId = `filter-popover_${filterType}`; + const optionId = `filter-popover-item_${option}`; + await testSubjects.existOrFail(popoverId); + await testSubjects.click(popoverId); + await testSubjects.existOrFail(optionId); + await testSubjects.click(optionId); + await testSubjects.click(popoverId); + }, async getSnapshotCount() { return { up: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.up'), diff --git a/x-pack/test_utils/stub_web_worker.ts b/x-pack/test_utils/stub_web_worker.ts new file mode 100644 index 0000000000000..2e7d5cf2098c8 --- /dev/null +++ b/x-pack/test_utils/stub_web_worker.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +if (!window.Worker) { + // @ts-ignore we aren't honoring the real Worker spec here + window.Worker = function Worker() { + this.postMessage = jest.fn(); + + // @ts-ignore TypeScript doesn't think this exists on the Worker interface + // https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate + this.terminate = jest.fn(); + }; +} diff --git a/yarn.lock b/yarn.lock index 2959b6b3eaf19..96bb533120aa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4759,11 +4759,6 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-dynamic-import@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" - integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== - acorn-globals@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" @@ -4838,7 +4833,7 @@ acorn@^6.0.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== -acorn@^6.0.5, acorn@^6.2.1: +acorn@^6.2.1: version "6.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== @@ -7984,7 +7979,7 @@ chroma-js@^1.4.1: resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.4.1.tgz#eb2d9c4d1ff24616be84b35119f4d26f8205f134" integrity sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ== -chrome-trace-event@^1.0.0, chrome-trace-event@^1.0.2: +chrome-trace-event@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== @@ -10963,11 +10958,6 @@ enabled@1.0.x: dependencies: env-variable "0.0.x" -encode-uri-query@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/encode-uri-query/-/encode-uri-query-1.0.1.tgz#e9c70d3e1aab71b039e55b38a166013508803ba8" - integrity sha1-6ccNPhqrcbA55Vs4oWYBNQiAO6g= - encodeurl@^1.0.2, encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -11613,14 +11603,6 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -18297,16 +18279,11 @@ load-source-map@^1.0.0: semver "^5.3.0" source-map "^0.5.6" -loader-runner@^2.3.0, loader-runner@^2.4.0: +loader-runner@^2.3.1, loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== -loader-runner@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.1.tgz#026f12fe7c3115992896ac02ba022ba92971b979" - integrity sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw== - loader-utils@1.2.3, loader-utils@^1.0.4, loader-utils@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" @@ -19228,7 +19205,7 @@ memory-fs@^0.2.0: resolved "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" integrity sha1-8rslNovBIeORwlIN6Slpyu4KApA= -memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: +memory-fs@^0.4.0, memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= @@ -19332,7 +19309,7 @@ microevent.ts@~0.1.1: resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== -micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: +micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -20309,7 +20286,7 @@ node-jose@1.1.0: util "^0.11.0" vm-browserify "0.0.4" -node-libs-browser@^2.0.0, node-libs-browser@^2.2.1: +node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== @@ -27379,7 +27356,7 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" -terser-webpack-plugin@^1.1.0, terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: +terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" integrity sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg== @@ -27408,35 +27385,16 @@ terser-webpack-plugin@^2.1.2: terser "^4.3.4" webpack-sources "^1.4.3" -terser@^4.1.2: - version "4.2.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.2.0.tgz#4b1b5f4424b426a7a47e80d6aae45e0d7979aef0" - integrity sha512-6lPt7lZdZ/13icQJp8XasFOwZjFJkxFFIb/N1fhYEQNoNI3Ilo3KABZ9OocZvZoB39r6SiIk/0+v/bt8nZoSeA== - dependencies: - commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - -terser@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.3.4.tgz#ad91bade95619e3434685d69efa621a5af5f877d" - integrity sha512-Kcrn3RiW8NtHBP0ssOAzwa2MsIRQ8lJWiBG/K7JgqPlomA3mtb2DEmp4/hrUA+Jujx+WZ02zqd7GYD+QRBB/2Q== +terser@^4.1.2, terser@^4.3.4: + version "4.6.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.1.tgz#913e35e0d38a75285a7913ba01d753c4089ebdbd" + integrity sha512-w0f2OWFD7ka3zwetgVAhNMeyzEbj39ht2Tb0qKflw9PmW9Qbo5tjTh01QJLkhO9t9RDDQYvk+WXqpECI2C6i2A== dependencies: commander "^2.20.0" source-map "~0.6.1" source-map-support "~0.5.12" -test-exclude@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.1.0.tgz#6ba6b25179d2d38724824661323b73e03c0c1de1" - integrity sha512-gwf0S2fFsANC55fSeSqpb8BYk6w3FDvwZxfNjeF6FRgvFa43r+7wRiA/Q0IxoRU37wB/LE8IQ4221BsNucTaCA== - dependencies: - arrify "^1.0.1" - minimatch "^3.0.4" - read-pkg-up "^4.0.0" - require-main-filename "^1.0.1" - -test-exclude@^5.2.3: +test-exclude@^5.0.0, test-exclude@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g== @@ -29955,7 +29913,7 @@ warning@^4.0.2: dependencies: loose-envify "^1.0.0" -watchpack@^1.5.0, watchpack@^1.6.0: +watchpack@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== @@ -30115,7 +30073,7 @@ webpack-sources@^1.1.0: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== @@ -30123,37 +30081,7 @@ webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack@4.33.0: - version "4.33.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.33.0.tgz#c30fc4307db432e5c5e3333aaa7c16a15a3b277e" - integrity sha512-ggWMb0B2QUuYso6FPZKUohOgfm+Z0sVFs8WwWuSH1IAvkWs428VDNmOlAxvHGTB9Dm/qOB/qtE5cRx5y01clxw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.0.5" - acorn-dynamic-import "^4.0.0" - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chrome-trace-event "^1.0.0" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.0" - json-parse-better-errors "^1.0.2" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - micromatch "^3.1.8" - mkdirp "~0.5.0" - neo-async "^2.5.0" - node-libs-browser "^2.0.0" - schema-utils "^1.0.0" - tapable "^1.1.0" - terser-webpack-plugin "^1.1.0" - watchpack "^1.5.0" - webpack-sources "^1.3.0" - -webpack@4.41.0, webpack@^4.41.0: +webpack@4.41.0, webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.0: version "4.41.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.0.tgz#db6a254bde671769f7c14e90a1a55e73602fc70b" integrity sha512-yNV98U4r7wX1VJAj5kyMsu36T8RPPQntcb5fJLOsMz/pt/WrKC0Vp1bAlqPLkA1LegSwQwf6P+kAbyhRKVQ72g== @@ -30182,35 +30110,6 @@ webpack@4.41.0, webpack@^4.41.0: watchpack "^1.6.0" webpack-sources "^1.4.1" -webpack@^4.33.0, webpack@^4.38.0: - version "4.39.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.39.3.tgz#a02179d1032156b713b6ec2da7e0df9d037def50" - integrity sha512-BXSI9M211JyCVc3JxHWDpze85CvjC842EvpRsVTc/d15YJGlox7GIDd38kJgWrb3ZluyvIjgenbLDMBQPDcxYQ== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - acorn "^6.2.1" - ajv "^6.10.2" - ajv-keywords "^3.4.1" - chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.3" - json-parse-better-errors "^1.0.2" - loader-runner "^2.4.0" - loader-utils "^1.2.3" - memory-fs "^0.4.1" - micromatch "^3.1.10" - mkdirp "^0.5.1" - neo-async "^2.6.1" - node-libs-browser "^2.2.1" - schema-utils "^1.0.0" - tapable "^1.1.3" - terser-webpack-plugin "^1.4.1" - watchpack "^1.6.0" - webpack-sources "^1.4.1" - websocket-driver@>=0.5.1: version "0.7.0" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"