diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4e9954eb18a39..6519bf9c493f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -150,8 +150,11 @@ # Pulse /packages/kbn-analytics/ @elastic/pulse /src/legacy/core_plugins/ui_metric/ @elastic/pulse +/src/plugins/telemetry/ @elastic/pulse +/src/plugins/telemetry_collection_manager/ @elastic/pulse +/src/plugins/telemetry_management_section/ @elastic/pulse /src/plugins/usage_collection/ @elastic/pulse -/x-pack/legacy/plugins/telemetry/ @elastic/pulse +/x-pack/plugins/telemetry_collection_xpack/ @elastic/pulse # Kibana Alerting Services /x-pack/legacy/plugins/alerting/ @elastic/kibana-alerting-services @@ -182,6 +185,7 @@ /x-pack/plugins/remote_clusters/ @elastic/es-ui /x-pack/legacy/plugins/rollup/ @elastic/es-ui /x-pack/plugins/searchprofiler/ @elastic/es-ui +/x-pack/plugins/painless_lab/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/upgrade_assistant/ @elastic/es-ui /x-pack/plugins/upgrade_assistant/ @elastic/es-ui diff --git a/.gitignore b/.gitignore index efb5c57774633..bd7a954f950e9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ package-lock.json *.sublime-* npm-debug.log* .tern-project +x-pack/legacy/plugins/apm/tsconfig.json +apm.tsconfig.json +/x-pack/legacy/plugins/apm/e2e/snapshots.js diff --git a/.i18nrc.json b/.i18nrc.json index bffe99bf3654b..78c4be6f4a356 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -3,6 +3,7 @@ "common.ui": "src/legacy/ui", "console": "src/plugins/console", "core": "src/core", + "discover": "src/plugins/discover", "dashboard": "src/plugins/dashboard", "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", @@ -35,8 +36,8 @@ "server": "src/legacy/server", "statusPage": "src/legacy/core_plugins/status_page", "telemetry": [ - "src/legacy/core_plugins/telemetry", - "src/plugins/telemetry" + "src/plugins/telemetry", + "src/plugins/telemetry_management_section" ], "tileMap": "src/legacy/core_plugins/tile_map", "timelion": ["src/legacy/core_plugins/timelion", "src/legacy/core_plugins/vis_type_timelion", "src/plugins/timelion"], diff --git a/docs/development/core/public/kibana-plugin-core-public.app.mount.md b/docs/development/core/public/kibana-plugin-core-public.app.mount.md index c42f73ced95af..8a9dfd9e2e972 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.mount.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.mount.md @@ -14,5 +14,5 @@ mount: AppMount | AppMountDeprecated ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md index e5554be515077..fc99e2208220f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md @@ -17,5 +17,5 @@ export interface ApplicationSetup | --- | --- | | [register(app)](./kibana-plugin-core-public.applicationsetup.register.md) | Register an mountable application to the system. | | [registerAppUpdater(appUpdater$)](./kibana-plugin-core-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-core-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-core-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-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-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-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md index 92a7ae1c0deee..1735d5df943ae 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md @@ -8,7 +8,7 @@ > > -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-core-public.coresetup.getstartservices.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-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md index 834411de5d57c..a93bc61bac527 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md @@ -24,5 +24,5 @@ export interface ApplicationStart | --- | --- | | [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the absolute option to generate an absolute url (http://host:port/basePath/app/my-app)Note that when generating absolute urls, the protocol, host and port are determined from the browser location. | | [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md index 6e0fbb46e9a1e..11f661c4af2b3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md @@ -8,7 +8,7 @@ > > -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-core-public.coresetup.getstartservices.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-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md index d0b243859aab0..52a36b0b56f02 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md @@ -8,7 +8,7 @@ > > -The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md index 130689882495a..66b8a69d84a38 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md @@ -18,5 +18,5 @@ export declare type AppMountDeprecated = (contex ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md index 91b906cf83d01..e4fec4eae31b1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. +[StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index f211b740e84a3..c039bc19348cc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -19,14 +19,9 @@ export interface CoreSetup | [application](./kibana-plugin-core-public.coresetup.application.md) | ApplicationSetup | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [context](./kibana-plugin-core-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-core-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | +| [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | | [http](./kibana-plugin-core-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-core-public.httpsetup.md) | | [injectedMetadata](./kibana-plugin-core-public.coresetup.injectedmetadata.md) | {
getInjectedVar: (name: string, defaultValue?: any) => unknown;
} | exposed temporarily until https://github.com/elastic/kibana/issues/41990 done use \*only\* to retrieve config values. There is no way to set injected values in the new platform. Use the legacy platform API instead. | | [notifications](./kibana-plugin-core-public.coresetup.notifications.md) | NotificationsSetup | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | [uiSettings](./kibana-plugin-core-public.coresetup.uisettings.md) | IUiSettingsClient | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | - diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b8aa56eb2941b..adc87de2b9e7e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -38,7 +38,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppLeaveDefaultAction](./kibana-plugin-core-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-core-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | | [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | | [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) | | | [Capabilities](./kibana-plugin-core-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | | [ChromeBadge](./kibana-plugin-core-public.chromebadge.md) | | @@ -153,6 +153,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | +| [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | | [ToastInput](./kibana-plugin-core-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | diff --git a/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md new file mode 100644 index 0000000000000..02e896a6b47e5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md index 10a656363c0d0..ea8e610ee56de 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. +[StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 5b5803629cc86..b0eba8ac78063 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -19,15 +19,10 @@ export interface CoreSetup | [capabilities](./kibana-plugin-core-server.coresetup.capabilities.md) | CapabilitiesSetup | [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md) | | [context](./kibana-plugin-core-server.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | +| [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | | [uuid](./kibana-plugin-core-server.coresetup.uuid.md) | UuidServiceSetup | [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-server.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 54cf496b2d6af..a1158dc853918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -259,6 +259,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | +| [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | | [StringValidation](./kibana-plugin-core-server.stringvalidation.md) | Allows regex objects or a regex string | | [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) | UI element type to represent the settings. | diff --git a/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md new file mode 100644 index 0000000000000..4de781fc99cc1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index ea77d6f39389b..6964c070097c5 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -18,7 +18,9 @@ | [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | | +| [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) | Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. | | [SearchError](./kibana-plugin-plugins-data-public.searcherror.md) | | +| [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) | | | [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) | | | [TimeHistory](./kibana-plugin-plugins-data-public.timehistory.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md new file mode 100644 index 0000000000000..25e472817b46d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) > [(constructor)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) + +## RequestTimeoutError.(constructor) + +Constructs a new instance of the `RequestTimeoutError` class + +Signature: + +```typescript +constructor(message?: string); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| message | string | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md new file mode 100644 index 0000000000000..84b2fc3fe0b17 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) + +## RequestTimeoutError class + +Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. + +Signature: + +```typescript +export declare class RequestTimeoutError extends Error +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(message)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) | | Constructs a new instance of the RequestTimeoutError class | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md new file mode 100644 index 0000000000000..6eabefb9eb912 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [(constructor)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) + +## SearchInterceptor.(constructor) + +This class should be instantiated with a `requestTimeout` corresponding with how many ms after requests are initiated that they should automatically cancel. + +Signature: + +```typescript +constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| toasts | ToastsStart | | +| application | ApplicationStart | | +| requestTimeout | number | undefined | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md new file mode 100644 index 0000000000000..0451a2254dc40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) + +## SearchInterceptor.abortController property + +`abortController` used to signal all searches to abort. + +Signature: + +```typescript +protected abortController: AbortController; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md new file mode 100644 index 0000000000000..e44910161aa60 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) + +## SearchInterceptor.application property + +Signature: + +```typescript +protected readonly application: ApplicationStart; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md new file mode 100644 index 0000000000000..59b107c92424f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) + +## SearchInterceptor.getPendingCount$ property + +Returns an `Observable` over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. + +Signature: + +```typescript +getPendingCount$: () => import("rxjs").Observable; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md new file mode 100644 index 0000000000000..59938a755a99e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) + +## SearchInterceptor.hideToast property + +Signature: + +```typescript +protected hideToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md new file mode 100644 index 0000000000000..5799039de91bc --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) + +## SearchInterceptor.longRunningToast property + +The current long-running toast (if there is one). + +Signature: + +```typescript +protected longRunningToast?: Toast; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md new file mode 100644 index 0000000000000..0c7b123be72af --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -0,0 +1,33 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) + +## SearchInterceptor class + +Signature: + +```typescript +export declare class SearchInterceptor +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(toasts, application, requestTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | This class should be instantiated with a requestTimeout corresponding with how many ms after requests are initiated that they should automatically cancel. | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) | | AbortController | abortController used to signal all searches to abort. | +| [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) | | ApplicationStart | | +| [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) | | () => import("rxjs").Observable<number> | Returns an Observable over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. | +| [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) | | () => void | | +| [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) | | Toast | The current long-running toast (if there is one). | +| [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) | | number | undefined | | +| [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable<import("../../common/search").IEsSearchResponse<unknown>> | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates the pendingCount when the request is started/finalized. | +| [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) | | () => void | | +| [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) | | Set<Subscription> | The subscriptions from scheduling the automatic timeout for each request. | +| [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) | | ToastsStart | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md new file mode 100644 index 0000000000000..3123433762991 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) + +## SearchInterceptor.requestTimeout property + +Signature: + +```typescript +protected readonly requestTimeout?: number | undefined; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md new file mode 100644 index 0000000000000..80c98ab84fb40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) + +## SearchInterceptor.search property + +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates the `pendingCount` when the request is started/finalized. + +Signature: + +```typescript +search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md new file mode 100644 index 0000000000000..e495c72b57215 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) + +## SearchInterceptor.showToast property + +Signature: + +```typescript +protected showToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md new file mode 100644 index 0000000000000..072f67591f097 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) + +## SearchInterceptor.timeoutSubscriptions property + +The subscriptions from scheduling the automatic timeout for each request. + +Signature: + +```typescript +protected timeoutSubscriptions: Set; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md new file mode 100644 index 0000000000000..4953d17c89c39 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) + +## SearchInterceptor.toasts property + +Signature: + +```typescript +protected readonly toasts: ToastsStart; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index d179b9d9dcd82..e756eb9b72905 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -60,6 +60,7 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | +| [search](./kibana-plugin-plugins-data-server.search.md) | | ## Type Aliases @@ -69,5 +70,6 @@ | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | | [ISearch](./kibana-plugin-plugins-data-server.isearch.md) | | | [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) | | +| [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | | [TSearchStrategyProvider](./kibana-plugin-plugins-data-server.tsearchstrategyprovider.md) | Search strategy provider creates an instance of a search strategy with the request handler context bound to it. This way every search strategy can use whatever information they require from the request context. | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 71bb7b81ea420..a72c15190840a 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -193,7 +193,7 @@ that feature would not take any effect. `logging.rotate.everyBytes:`:: [experimental] *Default: 10485760* The maximum size of a log file (that is `not an exact` limit). After the limit is reached, a new log file is generated. The default size limit is 10485760 (10 MB) and -this option should be in the range of 102400 (100KB) to 1073741824 (1GB). +this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). `logging.rotate.keepFiles:`:: [experimental] *Default: 7* The number of most recent rotated log files to keep on disk. Older files are deleted during log rotation. The default value is 7. The `logging.rotate.keepFiles` @@ -203,7 +203,7 @@ option has to be in the range of 2 to 1024 files. the `logging.rotate.usePolling` is enabled. That option has to be in the range of 5000 to 3600000 milliseconds. `logging.rotate.usePolling:`:: [experimental] *Default: false* By default we try to understand the best way to monitoring -the log file. However, there is some systems where it could not be always accurate. In those cases, if needed, +the log file and warning about it. Please be aware there are some systems where watch api is not accurate. In those cases, in order to get the feature working, the `polling` method could be used enabling that option. `logging.silent:`:: *Default: false* Set the value of this setting to `true` to diff --git a/examples/alerting_example/public/application.tsx b/examples/alerting_example/public/application.tsx index d71db92d3d421..6ff5a7d0880b8 100644 --- a/examples/alerting_example/public/application.tsx +++ b/examples/alerting_example/public/application.tsx @@ -25,6 +25,7 @@ import { AppMountParameters, CoreStart, IUiSettingsClient, + DocLinksStart, ToastsSetup, } from '../../../src/core/public'; import { DataPublicPluginStart } from '../../../src/plugins/data/public'; @@ -45,6 +46,7 @@ export interface AlertingExampleComponentParams { data: DataPublicPluginStart; charts: ChartsPluginStart; uiSettings: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: ToastsSetup; } @@ -88,7 +90,7 @@ const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { }; export const renderApp = ( - { application, notifications, http, uiSettings }: CoreStart, + { application, notifications, http, uiSettings, docLinks }: CoreStart, deps: AlertingExamplePublicStartDeps, { appBasePath, element }: AppMountParameters ) => { @@ -99,6 +101,7 @@ export const renderApp = ( toastNotifications={notifications.toasts} http={http} uiSettings={uiSettings} + docLinks={docLinks} {...deps} />, element diff --git a/examples/alerting_example/public/components/create_alert.tsx b/examples/alerting_example/public/components/create_alert.tsx index 65b8a9412dcda..0541e0b18a2e1 100644 --- a/examples/alerting_example/public/components/create_alert.tsx +++ b/examples/alerting_example/public/components/create_alert.tsx @@ -33,6 +33,7 @@ export const CreateAlert = ({ triggers_actions_ui, charts, uiSettings, + docLinks, data, toastNotifications, }: AlertingExampleComponentParams) => { @@ -56,6 +57,7 @@ export const CreateAlert = ({ alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, toastNotifications, uiSettings, + docLinks, charts, dataFieldsFormats: data.fieldFormats, }} diff --git a/package.json b/package.json index 3421bf938cd80..4baffa8719fe3 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@elastic/apm-rum": "^4.6.0", "@elastic/charts": "^18.1.0", "@elastic/datemath": "5.0.2", - "@elastic/ems-client": "7.7.0", + "@elastic/ems-client": "7.7.1", "@elastic/eui": "21.0.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", @@ -162,7 +162,7 @@ "color": "1.0.3", "commander": "3.0.2", "compare-versions": "3.5.1", - "core-js": "^3.2.1", + "core-js": "^3.6.4", "css-loader": "^3.4.2", "d3": "3.5.17", "d3-cloud": "1.2.5", @@ -190,7 +190,7 @@ "hjson": "3.2.1", "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", - "https-proxy-agent": "^2.2.2", + "https-proxy-agent": "^5.0.0", "immer": "^1.5.0", "inert": "^5.1.0", "inline-style": "^2.0.0", @@ -348,7 +348,7 @@ "@types/lru-cache": "^5.1.0", "@types/markdown-it": "^0.0.7", "@types/minimatch": "^2.0.29", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", "@types/node": ">=10.17.17 <10.20.0", @@ -443,7 +443,7 @@ "jest": "^24.9.0", "jest-cli": "^24.9.0", "jest-raw-loader": "^1.0.1", - "jimp": "0.8.4", + "jimp": "^0.9.6", "json5": "^1.0.1", "karma": "3.1.4", "karma-chrome-launcher": "2.2.0", @@ -456,7 +456,7 @@ "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mock-http-server": "1.3.0", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", diff --git a/packages/kbn-es/src/artifact.js b/packages/kbn-es/src/artifact.js index 9ea78386269d9..83dcd1cf36d2e 100644 --- a/packages/kbn-es/src/artifact.js +++ b/packages/kbn-es/src/artifact.js @@ -117,11 +117,14 @@ async function getArtifactSpecForSnapshot(urlVersion, license, log) { const manifest = JSON.parse(json); const platform = process.platform === 'win32' ? 'windows' : process.platform; + const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; + const archive = manifest.archives.find( archive => archive.version === desiredVersion && archive.platform === platform && - archive.license === desiredLicense + archive.license === desiredLicense && + archive.architecture === arch ); if (!archive) { diff --git a/packages/kbn-es/src/artifact.test.js b/packages/kbn-es/src/artifact.test.js index 453eb1a9a7689..02e4d5318f63f 100644 --- a/packages/kbn-es/src/artifact.test.js +++ b/packages/kbn-es/src/artifact.test.js @@ -28,6 +28,7 @@ const log = new ToolingLog(); let MOCKS; const PLATFORM = process.platform === 'win32' ? 'windows' : process.platform; +const ARCHITECTURE = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; const MOCK_VERSION = 'test-version'; const MOCK_URL = 'http://127.0.0.1:12345'; const MOCK_FILENAME = 'test-filename'; @@ -38,13 +39,15 @@ const PERMANENT_SNAPSHOT_BASE_URL = const createArchive = (params = {}) => { const license = params.license || 'default'; + const architecture = params.architecture || ARCHITECTURE; return { license: 'default', + architecture, version: MOCK_VERSION, url: MOCK_URL + `/${license}`, platform: PLATFORM, - filename: MOCK_FILENAME + `.${license}`, + filename: MOCK_FILENAME + `-${architecture}.${license}`, ...params, }; }; @@ -77,6 +80,12 @@ beforeEach(() => { valid: { archives: [createArchive({ license: 'oss' }), createArchive({ license: 'default' })], }, + multipleArch: { + archives: [ + createArchive({ architecture: 'fake_arch', license: 'oss' }), + createArchive({ architecture: ARCHITECTURE, license: 'oss' }), + ], + }, }; }); @@ -95,7 +104,7 @@ const artifactTest = (requestedLicense, expectedLicense, fetchTimesCalled = 1) = expect(artifact.getUrl()).toEqual(MOCK_URL + `/${expectedLicense}`); expect(artifact.getChecksumUrl()).toEqual(MOCK_URL + `/${expectedLicense}.sha512`); expect(artifact.getChecksumType()).toEqual('sha512'); - expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `.${expectedLicense}`); + expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `-${ARCHITECTURE}.${expectedLicense}`); }; }; @@ -153,6 +162,17 @@ describe('Artifact', () => { }); }); + describe('with snapshots for multiple architectures', () => { + beforeEach(() => { + mockFetch(MOCKS.multipleArch); + }); + + it('should return artifact metadata for the correct architecture', async () => { + const artifact = await Artifact.getSnapshot('oss', MOCK_VERSION, log); + expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `-${ARCHITECTURE}.oss`); + }); + }); + describe('with custom snapshot manifest URL', () => { const CUSTOM_URL = 'http://www.creedthoughts.gov.www/creedthoughts'; diff --git a/packages/kbn-spec-to-console/README.md b/packages/kbn-spec-to-console/README.md index 6729f03b3d4db..bf60afd88f494 100644 --- a/packages/kbn-spec-to-console/README.md +++ b/packages/kbn-spec-to-console/README.md @@ -23,10 +23,10 @@ At the root of the Kibana repository, run the following commands: ```sh # OSS -yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/legacy/core_plugins/console/server/api_server/spec/generated" +yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json" # X-pack -yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/legacy/plugins/console_extensions/spec/generated" +yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/spec/generated" ``` ### Information used in Console that is not available in the REST spec diff --git a/packages/kbn-spec-to-console/bin/spec_to_console.js b/packages/kbn-spec-to-console/bin/spec_to_console.js index 20e870963e4b4..20b42c67f3b89 100644 --- a/packages/kbn-spec-to-console/bin/spec_to_console.js +++ b/packages/kbn-spec-to-console/bin/spec_to_console.js @@ -21,6 +21,7 @@ const fs = require('fs'); const path = require('path'); const program = require('commander'); const glob = require('glob'); +const chalk = require('chalk'); const packageJSON = require('../package.json'); const convert = require('../lib/convert'); @@ -37,10 +38,26 @@ if (!program.glob) { } const files = glob.sync(program.glob); -console.log(files.length, files); +const totalFilesCount = files.length; +let convertedFilesCount = 0; + +console.log(chalk.bold(`Detected files (count: ${totalFilesCount}):`)); +console.log(); +console.log(files); +console.log(); + files.forEach(file => { const spec = JSON.parse(fs.readFileSync(file)); - const output = JSON.stringify(convert(spec), null, 2); + const convertedSpec = convert(spec); + if (!Object.keys(convertedSpec).length) { + console.log( + // prettier-ignore + `${chalk.yellow('Detected')} ${chalk.grey(file)} but no endpoints were converted; ${chalk.yellow('skipping')}...` + ); + return; + } + const output = JSON.stringify(convertedSpec, null, 2); + ++convertedFilesCount; if (program.directory) { const outputName = path.basename(file); const outputPath = path.resolve(program.directory, outputName); @@ -54,3 +71,9 @@ files.forEach(file => { console.log(output); } }); + +console.log(); +// prettier-ignore +console.log(`${chalk.grey('Converted')} ${chalk.bold(`${convertedFilesCount}/${totalFilesCount}`)} ${chalk.grey('files')}`); +console.log(`Check your ${chalk.bold('git status')}.`); +console.log(); diff --git a/packages/kbn-spec-to-console/lib/convert.js b/packages/kbn-spec-to-console/lib/convert.js index 5dbdd6e1c94e4..88e3693d702e5 100644 --- a/packages/kbn-spec-to-console/lib/convert.js +++ b/packages/kbn-spec-to-console/lib/convert.js @@ -36,6 +36,11 @@ module.exports = spec => { */ Object.keys(spec).forEach(api => { const source = spec[api]; + + if (source.url.paths.every(path => Boolean(path.deprecated))) { + return; + } + if (!source.url) { return result; } diff --git a/packages/kbn-spec-to-console/lib/convert/params.js b/packages/kbn-spec-to-console/lib/convert/params.js index 86ac1667282f0..0d1747ae4f685 100644 --- a/packages/kbn-spec-to-console/lib/convert/params.js +++ b/packages/kbn-spec-to-console/lib/convert/params.js @@ -47,6 +47,7 @@ module.exports = params => { case 'date': case 'string': case 'number': + case 'number|string': result[param] = defaultValue || ''; break; case 'list': diff --git a/packages/kbn-ui-shared-deps/monaco.ts b/packages/kbn-ui-shared-deps/monaco.ts index 570aca86c484c..42801c69a3e2c 100644 --- a/packages/kbn-ui-shared-deps/monaco.ts +++ b/packages/kbn-ui-shared-deps/monaco.ts @@ -25,6 +25,8 @@ import 'monaco-editor/esm/vs/base/worker/defaultWorkerFactory'; import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js'; import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js'; // Needed for word-wise char navigation + import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; // Needed for suggestions import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature diff --git a/renovate.json5 b/renovate.json5 index e4836537df703..57f175d1afc8e 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -297,6 +297,14 @@ '@types/flot', ], }, + { + groupSlug: 'geojson', + groupName: 'geojson related packages', + packageNames: [ + 'geojson', + '@types/geojson', + ], + }, { groupSlug: 'getopts', groupName: 'getopts related packages', diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 1ca9b63a51d18..0d5d300ec3b79 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1233,11 +1233,11 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `chromeNavControls` | [`core.chrome.navControls.register{Left,Right}`](/docs/development/core/public/kibana-plugin-public.chromenavcontrols.md) | | | `contextMenuActions` | | Should be an API on the devTools plugin. | | `devTools` | | | -| `docViews` | | | +| `docViews` | [`plugins.discover.docViews.addDocView`](./src/plugins/discover/public/doc_views) | Should be an API on the discover plugin. | | `embeddableActions` | | Should be an API on the embeddables plugin. | | `embeddableFactories` | | Should be an API on the embeddables plugin. | -| `fieldFormatEditors` | | | -| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | +| `fieldFormatEditors` | | | +| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | | `hacks` | n/a | Just run the code in your plugin's `start` method. | | `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | | `indexManagement` | | Should be an API on the indexManagement plugin. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index b91afa3ae7dc0..f72e115fd24ff 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -208,15 +208,21 @@ export interface CoreSetup { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; - - /** - * Allows plugins to get access to APIs available in start inside async - * handlers, such as {@link App.mount}. Promise will not resolve until Core - * and plugin dependencies have completed `start`. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async + * handlers, such as {@link App.mount}. Promise will not resolve until Core + * and plugin dependencies have completed `start`. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Core services exposed to the `Plugin` start lifecycle * diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 37212a07ee631..eec12f2348176 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,7 +378,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) fatalErrors: FatalErrorsSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpSetup; // @deprecated @@ -1235,6 +1236,9 @@ export class SimpleSavedObject { _version?: SavedObject['version']; } +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 89fee92a7ef02..1b436bfd72622 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -352,15 +352,22 @@ export interface CoreSetup { uuid: UuidServiceSetup; /** {@link MetricsServiceSetup} */ metrics: MetricsServiceSetup; - /** - * Allows plugins to get access to APIs available in start inside async handlers. - * Promise will not resolve until Core and plugin dependencies have completed `start`. - * This should only be used inside handlers registered during `setup` that will only be executed - * after `start` lifecycle. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async handlers. + * Promise will not resolve until Core and plugin dependencies have completed `start`. + * This should only be used inside handlers registered during `setup` that will only be executed + * after `start` lifecycle. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Context passed to the plugins `start` method. * diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 229ffc4d21575..6d4181e5e1ab3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -629,7 +629,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) elasticsearch: ElasticsearchServiceSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpServiceSetup; // (undocumented) @@ -2269,6 +2270,9 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ path: Pick; }>; +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 54a8cdf638a78..6c6fc54638ee8 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,6 +36,8 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', + // apm scripts aren't used in production, ignore them + 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/src/legacy/core_plugins/application_usage/mappings.ts b/src/legacy/core_plugins/application_usage/mappings.ts deleted file mode 100644 index 39adc53f7e9ff..0000000000000 --- a/src/legacy/core_plugins/application_usage/mappings.ts +++ /dev/null @@ -1,36 +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. - */ - -export const mappings = { - application_usage_totals: { - properties: { - appId: { type: 'keyword' }, - numberOfClicks: { type: 'long' }, - minutesOnScreen: { type: 'float' }, - }, - }, - application_usage_transactional: { - properties: { - timestamp: { type: 'date' }, - appId: { type: 'keyword' }, - numberOfClicks: { type: 'long' }, - minutesOnScreen: { type: 'float' }, - }, - }, -}; diff --git a/src/legacy/core_plugins/application_usage/package.json b/src/legacy/core_plugins/application_usage/package.json deleted file mode 100644 index 5ab10a2f8d237..0000000000000 --- a/src/legacy/core_plugins/application_usage/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "application_usage", - "version": "kibana" -} \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.ts b/src/legacy/core_plugins/kibana/public/dashboard/index.ts index 5b9fb8c0b6360..8900d017ef81a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/index.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/index.ts @@ -21,7 +21,6 @@ import { PluginInitializerContext } from 'kibana/public'; import { DashboardPlugin } from './plugin'; export * from './np_ready/dashboard_constants'; -export { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards'; // Core will be looking for this when loading our plugin in the new platform export const plugin = (context: PluginInitializerContext) => { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts index f3bc2a4a4e155..d8f8882a218dd 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts @@ -17,7 +17,7 @@ * under the License. */ -import { DashboardDoc730ToLatest } from './types'; +import { DashboardDoc730ToLatest } from '../../../../../../plugins/dashboard/public'; import { isDoc } from '../../../migrations/is_doc'; export function isDashboardDoc( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts index 2189b53ac81ee..e37c8de08fec4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts @@ -44,8 +44,6 @@ import { RawSavedDashboardPanel620, RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, -} from './types'; -import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT, } from '../../../../../../plugins/dashboard/public'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts index 6b037fa63cf68..047ec15f9a5d6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts @@ -18,17 +18,17 @@ */ import { i18n } from '@kbn/i18n'; import semver from 'semver'; -import { GridData } from 'src/plugins/dashboard/public'; - import uuid from 'uuid'; import { + GridData, RawSavedDashboardPanelTo60, RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, RawSavedDashboardPanel610, RawSavedDashboardPanel620, -} from './types'; +} from '../../../../../../plugins/dashboard/public'; + import { SavedDashboardPanelTo60, SavedDashboardPanel620, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts index 86d399d219a26..34bb46ce5d407 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts @@ -24,7 +24,7 @@ import { DashboardDoc730ToLatest, RawSavedDashboardPanel730ToLatest, DashboardDocPre700, -} from './types'; +} from '../../../../../../plugins/dashboard/public'; const mockLogger = { warning: () => {}, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts index 1ab5738cf4752..56856f7b21303 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts @@ -20,7 +20,10 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectsMigrationLogger } from 'src/core/server'; import { inspect } from 'util'; -import { DashboardDoc730ToLatest, DashboardDoc700To720 } from './types'; +import { + DashboardDoc730ToLatest, + DashboardDoc700To720, +} from '../../../../../../plugins/dashboard/public'; import { isDashboardDoc } from './is_dashboard_doc'; import { moveFiltersToQuery } from './move_filters_to_query'; import { migratePanelsTo730 } from './migrate_to_730_panels'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx index 4e9942767186e..e21033ffe10ec 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx @@ -22,7 +22,7 @@ import { Subscription } from 'rxjs'; import { History } from 'history'; import { ViewMode } from '../../../../embeddable_api/public/np_ready/public'; -import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../plugins/dashboard/public'; import { DashboardAppState, SavedDashboardPanel } from './types'; import { IIndexPattern, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index e2236f2894c41..93c657a3ccc2a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -759,7 +759,7 @@ export class DashboardAppController { * When de-angularizing this code, please call the underlaying action function * directly and not via the top nav object. **/ - navActions[TopNavIds.ADD](); + navActions[TopNavIds.ADD_EXISTING](); }; $scope.enterEditMode = () => { dashboardStateManager.setFullScreenMode(false); @@ -852,7 +852,8 @@ export class DashboardAppController { showCloneModal(onClone, currentTitle); }; - navActions[TopNavIds.ADD] = () => { + + navActions[TopNavIds.ADD_EXISTING] = () => { if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { openAddPanelFlyout({ embeddable: dashboardContainer, @@ -894,7 +895,8 @@ export class DashboardAppController { share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: !dashboardConfig.getHideWriteControls(), + allowShortUrl: + !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, shareableUrl: unhashUrl(window.location.href), objectId: dash.id, objectType: 'dashboard', diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts index d26948e06c8cb..9f71b15511a70 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts @@ -23,7 +23,10 @@ import { Observable, Subscription } from 'rxjs'; import { Moment } from 'moment'; import { History } from 'history'; -import { DashboardContainer } from 'src/plugins/dashboard/public'; +import { + DashboardContainer, + SavedObjectDashboard, +} from '../../../../../../plugins/dashboard/public'; import { ViewMode } from '../../../../../../plugins/embeddable/public'; import { migrateLegacyQuery } from '../legacy_imports'; import { @@ -35,8 +38,6 @@ import { import { getAppStateDefaults, migrateAppState } from './lib'; import { convertPanelStateToSavedDashboardPanel } from './lib/embeddable_saved_object_converters'; import { FilterUtils } from './lib/filter_utils'; -import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; - import { DashboardAppState, DashboardAppStateDefaults, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts index 7d5a378885470..500ee7e28daa6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts @@ -17,7 +17,7 @@ * under the License. */ import { omit } from 'lodash'; -import { DashboardPanelState } from 'src/plugins/dashboard/public'; +import { DashboardPanelState } from '../../../../../../../plugins/dashboard/public'; import { SavedDashboardPanel } from '../types'; export function convertSavedDashboardPanelToPanelState( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts index eceb51f17d164..b3acefeba0146 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts @@ -18,7 +18,7 @@ */ import { ViewMode } from '../../../../../../../plugins/embeddable/public'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public'; import { DashboardAppStateDefaults } from '../types'; export function getAppStateDefaults( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts index ec8073c0f72f7..dee279550aa6a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts @@ -20,7 +20,7 @@ import _ from 'lodash'; import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; import { FilterUtils } from './filter_utils'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public'; import { DashboardAppState } from '../types'; export function updateSavedDashboard( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts index 60b2a33f720ec..53618f1cfe5fa 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts @@ -18,7 +18,7 @@ */ import { searchSourceMock } from '../../../../../../../plugins/data/public/mocks'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public/'; export function getSavedDashboardMock( config?: Partial diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts index 7188fab19d6f2..7a3cb4b7dad56 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts @@ -48,9 +48,10 @@ export function getTopNavConfig( ]; case ViewMode.EDIT: return [ + getCreateNewConfig(actions[TopNavIds.VISUALIZE]), getSaveConfig(actions[TopNavIds.SAVE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getAddConfig(actions[TopNavIds.ADD]), + getAddConfig(actions[TopNavIds.ADD_EXISTING]), getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), ]; @@ -161,6 +162,25 @@ function getAddConfig(action: NavAction) { }; } +/** + * @returns {kbnTopNavConfig} + */ +function getCreateNewConfig(action: NavAction) { + return { + emphasize: true, + iconType: 'plusInCircle', + id: 'addNew', + label: i18n.translate('kbn.dashboard.topNave.addNewButtonAriaLabel', { + defaultMessage: 'Create new', + }), + description: i18n.translate('kbn.dashboard.topNave.addNewConfigDescription', { + defaultMessage: 'Create a new panel on this dashboard', + }), + testId: 'dashboardAddNewPanelButton', + run: action, + }; +} + /** * @returns {kbnTopNavConfig} */ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts index c67d6891c18e7..748bfaaab6141 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts @@ -18,7 +18,6 @@ */ export const TopNavIds = { - ADD: 'add', SHARE: 'share', OPTIONS: 'options', SAVE: 'save', @@ -27,4 +26,5 @@ export const TopNavIds = { CLONE: 'clone', FULL_SCREEN: 'fullScreenMode', VISUALIZE: 'visualize', + ADD_EXISTING: 'addExisting', }; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts index 0f3a7e322ebf3..9f8682f13d811 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts @@ -25,19 +25,11 @@ import { RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, -} from '../migrations/types'; +} from '../../../../../../plugins/dashboard/public'; import { Query, Filter } from '../../../../../../plugins/data/public'; export type NavAction = (anchorElement?: any) => void; -export interface GridData { - w: number; - h: number; - x: number; - y: number; - i: string; -} - /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts index a9ee77921ed4a..7452807454fe7 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts @@ -49,8 +49,8 @@ import { KibanaLegacySetup, KibanaLegacyStart, } from '../../../../../plugins/kibana_legacy/public'; -import { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; +import { DashboardStart } from '../../../../../plugins/dashboard/public'; export interface DashboardPluginStartDependencies { data: DataPublicPluginStart; @@ -58,6 +58,7 @@ export interface DashboardPluginStartDependencies { navigation: NavigationStart; share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; + dashboard: DashboardStart; } export interface DashboardPluginSetupDependencies { @@ -74,6 +75,7 @@ export class DashboardPlugin implements Plugin { navigation: NavigationStart; share: SharePluginStart; dashboardConfig: KibanaLegacyStart['dashboardConfig']; + dashboard: DashboardStart; } | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -129,13 +131,9 @@ export class DashboardPlugin implements Plugin { share, data: dataStart, dashboardConfig, + dashboard: { getSavedDashboardLoader }, } = this.startDependencies; - const savedDashboards = createSavedDashboardLoader({ - savedObjectsClient, - indexPatterns: dataStart.indexPatterns, - chrome: coreStart.chrome, - overlays: coreStart.overlays, - }); + const savedDashboards = getSavedDashboardLoader(); const deps: RenderDeps = { pluginInitializerContext: this.initializerContext, @@ -199,6 +197,7 @@ export class DashboardPlugin implements Plugin { data, share, kibanaLegacy: { dashboardConfig }, + dashboard, }: DashboardPluginStartDependencies ) { this.startDependencies = { @@ -208,6 +207,7 @@ export class DashboardPlugin implements Plugin { navigation, share, dashboardConfig, + dashboard, }; } diff --git a/src/legacy/core_plugins/kibana/public/discover/build_services.ts b/src/legacy/core_plugins/kibana/public/discover/build_services.ts index 282eef0c983eb..f881eb96e4e81 100644 --- a/src/legacy/core_plugins/kibana/public/discover/build_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/build_services.ts @@ -35,10 +35,13 @@ import { import { DiscoverStartPlugins } from './plugin'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; import { ChartsPluginStart } from '../../../../../plugins/charts/public'; import { VisualizationsStart } from '../../../visualizations/public'; -import { createSavedSearchesLoader, SavedSearch } from '../../../../../plugins/discover/public'; +import { + createSavedSearchesLoader, + DocViewerComponent, + SavedSearch, +} from '../../../../../plugins/discover/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -47,7 +50,7 @@ export interface DiscoverServices { core: CoreStart; data: DataPublicPluginStart; docLinks: DocLinksStart; - docViewsRegistry: DocViewsRegistry; + DocViewer: DocViewerComponent; history: History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; @@ -64,8 +67,7 @@ export interface DiscoverServices { } export async function buildServices( core: CoreStart, - plugins: DiscoverStartPlugins, - docViewsRegistry: DocViewsRegistry + plugins: DiscoverStartPlugins ): Promise { const services = { savedObjectsClient: core.savedObjects.client, @@ -81,7 +83,7 @@ export async function buildServices( core, data: plugins.data, docLinks: core.docLinks, - docViewsRegistry, + DocViewer: plugins.discover.docViews.DocViewer, history: createHashHistory(), theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index d369eb9679de6..7a3a6949baa94 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -75,7 +75,6 @@ export { EsQuerySortValue, SortDirection, } from '../../../../../plugins/data/public'; -export { ElasticSearchHit } from './np_ready/doc_views/doc_views_types'; export { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; // @ts-ignore export { buildPointSeriesData } from 'ui/agg_response/point_series/point_series'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js index 674f40d0186e5..9efddc5275069 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js @@ -26,7 +26,7 @@ import { fetchAnchorProvider } from '../api/anchor'; import { fetchContextProvider } from '../api/context'; import { getQueryParameterActions } from '../query_parameters'; import { FAILURE_REASONS, LOADING_STATUS } from './constants'; -import { MarkdownSimple } from '../../../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../../../plugins/kibana_react/public'; export function QueryActionsProvider(Promise) { const { filterManager, indexPatterns } = getServices(); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js index b020113381992..47e50f3cc3d4b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { FieldName } from './field_name/field_name'; +import { FieldName } from '../../../../../../../../plugins/discover/public'; import { getServices, wrapInI18nContext } from '../../../kibana_services'; export function FieldNameDirectiveProvider(reactDirective) { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts rename to src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx index 6ba47b839563b..90e061ac1aa05 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx @@ -17,11 +17,15 @@ * under the License. */ -import { DocViewer } from '../components/doc_viewer/doc_viewer'; +import React from 'react'; +import { getServices } from '../../kibana_services'; export function createDocViewerDirective(reactDirective: any) { return reactDirective( - DocViewer, + (props: any) => { + const { DocViewer } = getServices(); + return ; + }, [ 'hit', ['indexPattern', { watchDepth: 'reference' }], diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss index 0491430e5fddd..7161560f8fda4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss @@ -1,3 +1,2 @@ @import 'fetch_error/index'; @import 'field_chooser/index'; -@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx index 2278b243ecc14..1d19dc112d193 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx @@ -24,19 +24,13 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; -jest.mock('../doc_viewer/doc_viewer', () => ({ - DocViewer: () => null, -})); - jest.mock('../../../kibana_services', () => { return { getServices: () => ({ metadata: { branch: 'test', }, - getDocViewsSorted: () => { - return []; - }, + DocViewer: () => null, }), }; }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx index 819eb9df592bd..28a17dbdb67b7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { DocViewer } from '../doc_viewer/doc_viewer'; import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; -import { ElasticSearchHit, getServices } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export interface ElasticSearchResult { hits: { @@ -61,6 +61,7 @@ export interface DocProps { } export function Doc(props: DocProps) { + const { DocViewer } = getServices(); const [reqState, hit, indexPattern] = useEsDocSearch(props); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index 6cffc2cc533b0..2cd264578a596 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -17,8 +17,9 @@ * under the License. */ import { useEffect, useState } from 'react'; -import { ElasticSearchHit, IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export enum ElasticRequestState { Loading, diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts index ba671a64592a5..d3cdeb49fba71 100644 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts @@ -19,7 +19,6 @@ import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import angular, { auto } from 'angular'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; @@ -41,10 +40,7 @@ import { KibanaLegacySetup, AngularRenderedAppUpdater, } from '../../../../../plugins/kibana_legacy/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; -import { DocViewInput, DocViewInputFn } from './np_ready/doc_views/doc_views_types'; -import { DocViewTable } from './np_ready/components/table/table'; -import { JsonCodeBlock } from './np_ready/components/json_code_block/json_code_block'; +import { DiscoverSetup, DiscoverStart } from '../../../../../plugins/discover/public'; import { HomePublicPluginSetup } from '../../../../../plugins/home/public'; import { VisualizationsStart, @@ -52,15 +48,6 @@ import { } from '../../../visualizations/public/np_ready/public'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ -export interface DiscoverSetup { - addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; -} -export type DiscoverStart = void; export interface DiscoverSetupPlugins { uiActions: UiActionsSetup; embeddable: EmbeddableSetup; @@ -68,6 +55,7 @@ export interface DiscoverSetupPlugins { home: HomePublicPluginSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + discover: DiscoverSetup; } export interface DiscoverStartPlugins { uiActions: UiActionsStart; @@ -78,6 +66,7 @@ export interface DiscoverStartPlugins { share: SharePluginStart; inspector: any; visualizations: VisualizationsStart; + discover: DiscoverStart; } const innerAngularName = 'app/discover'; const embeddableAngularName = 'app/discoverEmbeddable'; @@ -87,10 +76,9 @@ const embeddableAngularName = 'app/discoverEmbeddable'; * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular * Discover provides embeddables, those contain a slimmer Angular */ -export class DiscoverPlugin implements Plugin { +export class DiscoverPlugin implements Plugin { private servicesInitialized: boolean = false; private innerAngularInitialized: boolean = false; - private docViewsRegistry: DocViewsRegistry | null = null; private embeddableInjector: auto.IInjectorService | null = null; private getEmbeddableInjector: (() => Promise) | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -103,7 +91,7 @@ export class DiscoverPlugin implements Plugin { public initializeInnerAngular?: () => void; public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; - setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup { + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/kibana'), defaultSubUrl: '#/discover', @@ -130,21 +118,7 @@ export class DiscoverPlugin implements Plugin { }; this.getEmbeddableInjector = this.getInjector.bind(this); - this.docViewsRegistry = new DocViewsRegistry(this.getEmbeddableInjector); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.table.tableTitle', { - defaultMessage: 'Table', - }), - order: 10, - component: DocViewTable, - }); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.json.jsonTitle', { - defaultMessage: 'JSON', - }), - order: 20, - component: JsonCodeBlock, - }); + plugins.discover.docViews.setAngularInjectorGetter(this.getEmbeddableInjector); plugins.kibanaLegacy.registerLegacyApp({ id: 'discover', title: 'Discover', @@ -172,14 +146,10 @@ export class DiscoverPlugin implements Plugin { }, }); registerFeature(plugins.home); - this.registerEmbeddable(core, plugins); - return { - addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), - }; } - start(core: CoreStart, plugins: DiscoverStartPlugins): DiscoverStart { + start(core: CoreStart, plugins: DiscoverStartPlugins) { // we need to register the application service at setup, but to render it // there are some start dependencies necessary, for this reason // initializeInnerAngular + initializeServices are assigned at start and used @@ -198,7 +168,7 @@ export class DiscoverPlugin implements Plugin { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.docViewsRegistry!); + const services = await buildServices(core, plugins); setServices(services); this.servicesInitialized = true; diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg new file mode 100644 index 0000000000000..feccb88a3f34b --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts index cb9ac0e01bb7f..f3a37e2b7348f 100644 --- a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts +++ b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts @@ -21,7 +21,6 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; import { SavedObjectLoader } from '../../../../../plugins/saved_objects/public'; -import { createSavedDashboardLoader } from '../dashboard'; import { start as visualizations } from '../../../visualizations/public/np_ready/public/legacy'; import { createSavedSearchesLoader } from '../../../../../plugins/discover/public'; @@ -70,7 +69,7 @@ savedObjectManagementRegistry.register({ savedObjectManagementRegistry.register({ id: 'savedDashboards', - service: createSavedDashboardLoader(services), + service: npStart.plugins.dashboard.getSavedDashboardLoader(), title: i18n.translate('kbn.dashboard.savedDashboardsTitle', { defaultMessage: 'dashboards', }), diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap index c1241d5d7c1e5..728944f3ccbfe 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap @@ -53,6 +53,7 @@ exports[`Relationships should render dashboards normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -72,6 +73,7 @@ exports[`Relationships should render dashboards normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -117,6 +119,7 @@ exports[`Relationships should render dashboards normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -263,6 +266,7 @@ exports[`Relationships should render index patterns normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -282,6 +286,7 @@ exports[`Relationships should render index patterns normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -327,6 +332,7 @@ exports[`Relationships should render index patterns normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -429,6 +435,7 @@ exports[`Relationships should render searches normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -448,6 +455,7 @@ exports[`Relationships should render searches normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -493,6 +501,7 @@ exports[`Relationships should render searches normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -595,6 +604,7 @@ exports[`Relationships should render visualizations normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -614,6 +624,7 @@ exports[`Relationships should render visualizations normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -659,6 +670,7 @@ exports[`Relationships should render visualizations normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js index ee9fb70e31fb2..ce3415ad2f0e7 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js @@ -135,6 +135,7 @@ export class Relationships extends Component { aria-label={getSavedObjectLabel(type)} type={object.meta.icon || 'apps'} size="s" + data-test-subj="relationshipsObjectType" /> ); @@ -149,6 +150,7 @@ export class Relationships extends Component { dataType: 'string', sortable: false, width: '125px', + 'data-test-subj': 'directRelationship', render: relationship => { if (relationship === 'parent') { return ( @@ -187,10 +189,16 @@ export class Relationships extends Component { const { path } = object.meta.inAppUrl || {}; const canGoInApp = this.props.canGoInApp(object); if (!canGoInApp) { - return {title || getDefaultTitle(object)}; + return ( + + {title || getDefaultTitle(object)} + + ); } return ( - {title || getDefaultTitle(object)} + + {title || getDefaultTitle(object)} + ); }, }, @@ -211,6 +219,7 @@ export class Relationships extends Component { ), type: 'icon', icon: 'inspect', + 'data-test-subj': 'relationshipsTableAction-inspect', onClick: object => goInspectObject(object), available: object => !!object.meta.editUrl, }, @@ -295,6 +304,9 @@ export class Relationships extends Component { columns={columns} pagination={true} search={search} + rowProps={() => ({ + 'data-test-subj': `relationshipsTableRow`, + })} /> ); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js index 386b35399b754..5342693113bca 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js @@ -186,6 +186,7 @@ export class Table extends PureComponent { aria-label={getSavedObjectLabel(type)} type={object.meta.icon || 'apps'} size="s" + data-test-subj="objectType" /> ); diff --git a/src/legacy/core_plugins/kibana_react/package.json b/src/legacy/core_plugins/kibana_react/package.json deleted file mode 100644 index 3f7cf717a1963..0000000000000 --- a/src/legacy/core_plugins/kibana_react/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "kibana_react", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/kibana_react/public/index.scss b/src/legacy/core_plugins/kibana_react/public/index.scss deleted file mode 100644 index 14b4687c459e1..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -@import './markdown/index'; diff --git a/src/legacy/core_plugins/telemetry/common/constants.ts b/src/legacy/core_plugins/telemetry/common/constants.ts deleted file mode 100644 index b44bf319e6627..0000000000000 --- a/src/legacy/core_plugins/telemetry/common/constants.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; - -/* - * config options opt into telemetry - * @type {string} - */ -export const CONFIG_TELEMETRY = 'telemetry:optIn'; -/* - * config description for opting into telemetry - * @type {string} - */ -export const getConfigTelemetryDesc = () => { - return i18n.translate('telemetry.telemetryConfigDescription', { - defaultMessage: - 'Help us improve the Elastic Stack by providing usage statistics for basic features. We will not share this data outside of Elastic.', - }); -}; - -/** - * The amount of time, in milliseconds, to wait between reports when enabled. - * - * Currently 24 hours. - * @type {Number} - */ -export const REPORT_INTERVAL_MS = 86400000; - -/** - * Link to the Elastic Telemetry privacy statement. - */ -export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/privacy-statement`; - -/** - * The type name used within the Monitoring index to publish localization stats. - * @type {string} - */ -export const KIBANA_LOCALIZATION_STATS_TYPE = 'localization'; - -/** - * The type name used to publish telemetry plugin stats. - * @type {string} - */ -export const TELEMETRY_STATS_TYPE = 'telemetry'; - -/** - * UI metric usage type - * @type {string} - */ -export const UI_METRIC_USAGE_TYPE = 'ui_metric'; - -/** - * Application Usage type - */ -export const APPLICATION_USAGE_TYPE = 'application_usage'; - -/** - * Link to Advanced Settings. - */ -export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; - -/** - * The type name used within the Monitoring index to publish management stats. - * @type {string} - */ -export const KIBANA_STACK_MANAGEMENT_STATS_TYPE = 'stack_management'; diff --git a/src/legacy/core_plugins/telemetry/index.ts b/src/legacy/core_plugins/telemetry/index.ts deleted file mode 100644 index 1e88e7d65cffd..0000000000000 --- a/src/legacy/core_plugins/telemetry/index.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as Rx from 'rxjs'; -import { resolve } from 'path'; -import JoiNamespace from 'joi'; -import { Server } from 'hapi'; -import { PluginInitializerContext } from 'src/core/server'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { getConfigPath } from '../../../core/server/path'; -// @ts-ignore -import mappings from './mappings.json'; -import { - telemetryPlugin, - replaceTelemetryInjectedVars, - FetcherTask, - PluginsSetup, - handleOldSettings, -} from './server'; - -const ENDPOINT_VERSION = 'v2'; - -const telemetry = (kibana: any) => { - return new kibana.Plugin({ - id: 'telemetry', - configPrefix: 'telemetry', - publicDir: resolve(__dirname, 'public'), - require: ['elasticsearch'], - config(Joi: typeof JoiNamespace) { - return Joi.object({ - enabled: Joi.boolean().default(true), - allowChangingOptInStatus: Joi.boolean().default(true), - optIn: Joi.when('allowChangingOptInStatus', { - is: false, - then: Joi.valid(true).default(true), - otherwise: Joi.boolean().default(true), - }), - // `config` is used internally and not intended to be set - config: Joi.string().default(getConfigPath()), - banner: Joi.boolean().default(true), - url: Joi.when('$dev', { - is: true, - then: Joi.string().default( - `https://telemetry-staging.elastic.co/xpack/${ENDPOINT_VERSION}/send` - ), - otherwise: Joi.string().default( - `https://telemetry.elastic.co/xpack/${ENDPOINT_VERSION}/send` - ), - }), - optInStatusUrl: Joi.when('$dev', { - is: true, - then: Joi.string().default( - `https://telemetry-staging.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send` - ), - otherwise: Joi.string().default( - `https://telemetry.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send` - ), - }), - sendUsageFrom: Joi.string() - .allow(['server', 'browser']) - .default('browser'), - }).default(); - }, - uiExports: { - managementSections: ['plugins/telemetry/views/management'], - savedObjectSchemas: { - telemetry: { - isNamespaceAgnostic: true, - }, - }, - async replaceInjectedVars(originalInjectedVars: any, request: any, server: any) { - const telemetryInjectedVars = await replaceTelemetryInjectedVars(request, server); - return Object.assign({}, originalInjectedVars, telemetryInjectedVars); - }, - injectDefaultVars(server: Server) { - const config = server.config(); - return { - telemetryEnabled: config.get('telemetry.enabled'), - telemetryUrl: config.get('telemetry.url'), - telemetryBanner: - config.get('telemetry.allowChangingOptInStatus') !== false && - config.get('telemetry.banner'), - telemetryOptedIn: config.get('telemetry.optIn'), - telemetryOptInStatusUrl: config.get('telemetry.optInStatusUrl'), - allowChangingOptInStatus: config.get('telemetry.allowChangingOptInStatus'), - telemetrySendUsageFrom: config.get('telemetry.sendUsageFrom'), - telemetryNotifyUserAboutOptInDefault: false, - }; - }, - mappings, - }, - postInit(server: Server) { - const fetcherTask = new FetcherTask(server); - fetcherTask.start(); - }, - async init(server: Server) { - const { usageCollection } = server.newPlatform.setup.plugins; - const initializerContext = { - env: { - packageInfo: { - version: server.config().get('pkg.version'), - }, - }, - config: { - create() { - const config = server.config(); - return Rx.of({ - enabled: config.get('telemetry.enabled'), - optIn: config.get('telemetry.optIn'), - config: config.get('telemetry.config'), - banner: config.get('telemetry.banner'), - url: config.get('telemetry.url'), - allowChangingOptInStatus: config.get('telemetry.allowChangingOptInStatus'), - }); - }, - }, - } as PluginInitializerContext; - - try { - await handleOldSettings(server); - } catch (err) { - server.log(['warning', 'telemetry'], 'Unable to update legacy telemetry configs.'); - } - - const pluginsSetup: PluginsSetup = { - usageCollection, - }; - - const npPlugin = telemetryPlugin(initializerContext); - await npPlugin.setup(server.newPlatform.setup.core, pluginsSetup, server); - await npPlugin.start(server.newPlatform.start.core); - }, - }); -}; - -// eslint-disable-next-line import/no-default-export -export default telemetry; diff --git a/src/legacy/core_plugins/telemetry/mappings.json b/src/legacy/core_plugins/telemetry/mappings.json deleted file mode 100644 index fa9cc93d6363a..0000000000000 --- a/src/legacy/core_plugins/telemetry/mappings.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "telemetry": { - "properties": { - "enabled": { - "type": "boolean" - }, - "sendUsageFrom": { - "ignore_above": 256, - "type": "keyword" - }, - "lastReported": { - "type": "date" - }, - "lastVersionChecked": { - "ignore_above": 256, - "type": "keyword" - }, - "userHasSeenNotice": { - "type": "boolean" - }, - "reportFailureCount": { - "type": "integer" - }, - "reportFailureVersion": { - "ignore_above": 256, - "type": "keyword" - } - } - } -} diff --git a/src/legacy/core_plugins/telemetry/package.json b/src/legacy/core_plugins/telemetry/package.json deleted file mode 100644 index 979e68cce742f..0000000000000 --- a/src/legacy/core_plugins/telemetry/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "telemetry", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/telemetry/public/views/management/management.tsx b/src/legacy/core_plugins/telemetry/public/views/management/management.tsx deleted file mode 100644 index c8ae410e0aa57..0000000000000 --- a/src/legacy/core_plugins/telemetry/public/views/management/management.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React from 'react'; -import routes from 'ui/routes'; -import { npStart, npSetup } from 'ui/new_platform'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TelemetryManagementSection } from '../../../../../../plugins/telemetry/public/components'; - -routes.defaults(/\/management/, { - resolve: { - telemetryManagementSection() { - const { telemetry } = npStart.plugins as any; - const { advancedSettings } = npSetup.plugins as any; - - if (telemetry && advancedSettings) { - const componentRegistry = advancedSettings.component; - const Component = (props: any) => ( - - ); - - componentRegistry.register( - componentRegistry.componentType.PAGE_FOOTER_COMPONENT, - Component, - true - ); - } - }, - }, -}); diff --git a/src/legacy/core_plugins/telemetry/server/collection_manager.ts b/src/legacy/core_plugins/telemetry/server/collection_manager.ts deleted file mode 100644 index ebac4bede85bb..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/collection_manager.ts +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { encryptTelemetry } from './collectors'; -import { CallCluster } from '../../elasticsearch'; -import { UsageCollectionSetup } from '../../../../plugins/usage_collection/server'; -import { Cluster } from '../../elasticsearch'; -import { ESLicense } from './telemetry_collection/get_local_license'; - -export type EncryptedStatsGetterConfig = { unencrypted: false } & { - server: any; - start: string; - end: string; -}; - -export type UnencryptedStatsGetterConfig = { unencrypted: true } & { - req: any; - start: string; - end: string; -}; - -export interface ClusterDetails { - clusterUuid: string; -} - -export interface StatsCollectionConfig { - usageCollection: UsageCollectionSetup; - callCluster: CallCluster; - server: any; - start: string | number; - end: string | number; -} - -export interface BasicStatsPayload { - timestamp: string; - cluster_uuid: string; - cluster_name: string; - version: string; - cluster_stats: object; - collection?: string; - stack_stats: object; -} - -export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig; -export type ClusterDetailsGetter = (config: StatsCollectionConfig) => Promise; -export type StatsGetter = ( - clustersDetails: ClusterDetails[], - config: StatsCollectionConfig -) => Promise; -export type LicenseGetter = ( - clustersDetails: ClusterDetails[], - config: StatsCollectionConfig -) => Promise<{ [clusterUuid: string]: ESLicense | undefined }>; - -interface CollectionConfig { - title: string; - priority: number; - esCluster: string | Cluster; - statsGetter: StatsGetter; - clusterDetailsGetter: ClusterDetailsGetter; - licenseGetter: LicenseGetter; -} -interface Collection { - statsGetter: StatsGetter; - licenseGetter: LicenseGetter; - clusterDetailsGetter: ClusterDetailsGetter; - esCluster: string | Cluster; - title: string; -} - -export class TelemetryCollectionManager { - private usageGetterMethodPriority = -1; - private collections: Collection[] = []; - - public setCollection = (collectionConfig: CollectionConfig) => { - const { - title, - priority, - esCluster, - statsGetter, - clusterDetailsGetter, - licenseGetter, - } = collectionConfig; - - if (typeof priority !== 'number') { - throw new Error('priority must be set.'); - } - if (priority === this.usageGetterMethodPriority) { - throw new Error(`A Usage Getter with the same priority is already set.`); - } - - if (priority > this.usageGetterMethodPriority) { - if (!statsGetter) { - throw Error('Stats getter method not set.'); - } - if (!esCluster) { - throw Error('esCluster name must be set for the getCluster method.'); - } - if (!clusterDetailsGetter) { - throw Error('Cluster UUIds method is not set.'); - } - if (!licenseGetter) { - throw Error('License getter method not set.'); - } - - this.collections.unshift({ - licenseGetter, - statsGetter, - clusterDetailsGetter, - esCluster, - title, - }); - this.usageGetterMethodPriority = priority; - } - }; - - private getStatsCollectionConfig = async ( - collection: Collection, - config: StatsGetterConfig - ): Promise => { - const { start, end } = config; - const server = config.unencrypted ? config.req.server : config.server; - const { callWithRequest, callWithInternalUser } = - typeof collection.esCluster === 'string' - ? server.plugins.elasticsearch.getCluster(collection.esCluster) - : collection.esCluster; - const callCluster = config.unencrypted - ? (...args: any[]) => callWithRequest(config.req, ...args) - : callWithInternalUser; - - const { usageCollection } = server.newPlatform.setup.plugins; - return { server, callCluster, start, end, usageCollection }; - }; - - private getOptInStatsForCollection = async ( - collection: Collection, - optInStatus: boolean, - statsCollectionConfig: StatsCollectionConfig - ) => { - const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig); - return clustersDetails.map(({ clusterUuid }) => ({ - cluster_uuid: clusterUuid, - opt_in_status: optInStatus, - })); - }; - - private getUsageForCollection = async ( - collection: Collection, - statsCollectionConfig: StatsCollectionConfig - ) => { - const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig); - - if (clustersDetails.length === 0) { - // don't bother doing a further lookup, try next collection. - return; - } - - const [stats, licenses] = await Promise.all([ - collection.statsGetter(clustersDetails, statsCollectionConfig), - collection.licenseGetter(clustersDetails, statsCollectionConfig), - ]); - - return stats.map(stat => { - const license = licenses[stat.cluster_uuid]; - return { - ...(license ? { license } : {}), - ...stat, - collectionSource: collection.title, - }; - }); - }; - - public getOptInStats = async (optInStatus: boolean, config: StatsGetterConfig) => { - for (const collection of this.collections) { - const statsCollectionConfig = await this.getStatsCollectionConfig(collection, config); - try { - const optInStats = await this.getOptInStatsForCollection( - collection, - optInStatus, - statsCollectionConfig - ); - if (optInStats && optInStats.length) { - statsCollectionConfig.server.log( - ['debug', 'telemetry', 'collection'], - `Got Opt In stats using ${collection.title} collection.` - ); - if (config.unencrypted) { - return optInStats; - } - const isDev = statsCollectionConfig.server.config().get('env.dev'); - return encryptTelemetry(optInStats, isDev); - } - } catch (err) { - statsCollectionConfig.server.log( - ['debu', 'telemetry', 'collection'], - `Failed to collect any opt in stats with registered collections.` - ); - // swallow error to try next collection; - } - } - - return []; - }; - public getStats = async (config: StatsGetterConfig) => { - for (const collection of this.collections) { - const statsCollectionConfig = await this.getStatsCollectionConfig(collection, config); - try { - const usageData = await this.getUsageForCollection(collection, statsCollectionConfig); - if (usageData && usageData.length) { - statsCollectionConfig.server.log( - ['debug', 'telemetry', 'collection'], - `Got Usage using ${collection.title} collection.` - ); - if (config.unencrypted) { - return usageData; - } - const isDev = statsCollectionConfig.server.config().get('env.dev'); - return encryptTelemetry(usageData, isDev); - } - } catch (err) { - statsCollectionConfig.server.log( - ['debug', 'telemetry', 'collection'], - `Failed to collect any usage with registered collections.` - ); - // swallow error to try next collection; - } - } - - return []; - }; -} - -export const telemetryCollectionManager = new TelemetryCollectionManager(); diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts deleted file mode 100644 index 0b9f0526988c8..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/plugin.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - CoreSetup, - PluginInitializerContext, - ISavedObjectsRepository, - CoreStart, -} from 'src/core/server'; -import { Server } from 'hapi'; -import { registerRoutes } from './routes'; -import { registerCollection } from './telemetry_collection'; -import { UsageCollectionSetup } from '../../../../plugins/usage_collection/server'; -import { - registerUiMetricUsageCollector, - registerTelemetryUsageCollector, - registerLocalizationUsageCollector, - registerTelemetryPluginUsageCollector, - registerManagementUsageCollector, - registerApplicationUsageCollector, -} from './collectors'; - -export interface PluginsSetup { - usageCollection: UsageCollectionSetup; -} - -export class TelemetryPlugin { - private readonly currentKibanaVersion: string; - private savedObjectsClient?: ISavedObjectsRepository; - - constructor(initializerContext: PluginInitializerContext) { - this.currentKibanaVersion = initializerContext.env.packageInfo.version; - } - - public setup(core: CoreSetup, { usageCollection }: PluginsSetup, server: Server) { - const currentKibanaVersion = this.currentKibanaVersion; - - registerCollection(); - registerRoutes({ core, currentKibanaVersion, server }); - - const getSavedObjectsClient = () => this.savedObjectsClient; - - registerTelemetryPluginUsageCollector(usageCollection, server); - registerLocalizationUsageCollector(usageCollection, server); - registerTelemetryUsageCollector(usageCollection, server); - registerUiMetricUsageCollector(usageCollection, getSavedObjectsClient); - registerManagementUsageCollector(usageCollection, server); - registerApplicationUsageCollector(usageCollection, getSavedObjectsClient); - } - - public start({ savedObjects }: CoreStart) { - this.savedObjectsClient = savedObjects.createInternalRepository(); - } -} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts deleted file mode 100644 index ccbc28f6cbadb..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Joi from 'joi'; -import moment from 'moment'; -import { boomify } from 'boom'; -import { CoreSetup } from 'src/core/server'; -import { Legacy } from 'kibana'; -import { getTelemetryAllowChangingOptInStatus } from '../telemetry_config'; -import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats'; - -import { - TelemetrySavedObjectAttributes, - updateTelemetrySavedObject, -} from '../telemetry_repository'; - -interface RegisterOptInRoutesParams { - core: CoreSetup; - currentKibanaVersion: string; - server: Legacy.Server; -} - -export function registerTelemetryOptInRoutes({ - server, - currentKibanaVersion, -}: RegisterOptInRoutesParams) { - server.route({ - method: 'POST', - path: '/api/telemetry/v2/optIn', - options: { - validate: { - payload: Joi.object({ - enabled: Joi.bool().required(), - }), - }, - }, - handler: async (req: any, h: any) => { - try { - const newOptInStatus = req.payload.enabled; - const attributes: TelemetrySavedObjectAttributes = { - enabled: newOptInStatus, - lastVersionChecked: currentKibanaVersion, - }; - const config = req.server.config(); - const savedObjectsClient = req.getSavedObjectsClient(); - const configTelemetryAllowChangingOptInStatus = config.get( - 'telemetry.allowChangingOptInStatus' - ); - - const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ - telemetrySavedObject: savedObjectsClient, - configTelemetryAllowChangingOptInStatus, - }); - if (!allowChangingOptInStatus) { - return h.response({ error: 'Not allowed to change Opt-in Status.' }).code(400); - } - - const sendUsageFrom = config.get('telemetry.sendUsageFrom'); - if (sendUsageFrom === 'server') { - const optInStatusUrl = config.get('telemetry.optInStatusUrl'); - await sendTelemetryOptInStatus( - { optInStatusUrl, newOptInStatus }, - { - start: moment() - .subtract(20, 'minutes') - .toISOString(), - end: moment().toISOString(), - server: req.server, - unencrypted: false, - } - ); - } - - await updateTelemetrySavedObject(savedObjectsClient, attributes); - return h.response({}).code(200); - } catch (err) { - return boomify(err); - } - }, - }); -} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts deleted file mode 100644 index ee3241b0dc2ea..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_usage_stats.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Joi from 'joi'; -import { boomify } from 'boom'; -import { Legacy } from 'kibana'; -import { telemetryCollectionManager } from '../collection_manager'; - -export function registerTelemetryUsageStatsRoutes(server: Legacy.Server) { - server.route({ - method: 'POST', - path: '/api/telemetry/v2/clusters/_stats', - options: { - validate: { - payload: Joi.object({ - unencrypted: Joi.bool(), - timeRange: Joi.object({ - min: Joi.date().required(), - max: Joi.date().required(), - }).required(), - }), - }, - }, - handler: async (req: any, h: any) => { - const config = req.server.config(); - const start = req.payload.timeRange.min; - const end = req.payload.timeRange.max; - const unencrypted = req.payload.unencrypted; - - try { - return await telemetryCollectionManager.getStats({ - unencrypted, - server, - req, - start, - end, - }); - } catch (err) { - const isDev = config.get('env.dev'); - if (isDev) { - // don't ignore errors when running in dev mode - return boomify(err, { statusCode: err.status || 500 }); - } else { - const statusCode = unencrypted && err.status === 403 ? 403 : 200; - // ignore errors and return empty set - return h.response([]).code(statusCode); - } - } - }, - }); -} diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts deleted file mode 100644 index f09ee8623afac..0000000000000 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { getTelemetrySavedObject } from '../telemetry_repository'; -import { getTelemetryOptIn } from './get_telemetry_opt_in'; -import { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; -import { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; -import { getNotifyUserAboutOptInDefault } from './get_telemetry_notify_user_about_optin_default'; - -export async function replaceTelemetryInjectedVars(request: any, server: any) { - const config = server.config(); - const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom'); - const configTelemetryOptIn = config.get('telemetry.optIn'); - const configTelemetryAllowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus'); - const isRequestingApplication = request.path.startsWith('/app'); - - // Prevent interstitial screens (such as the space selector) from prompting for telemetry - if (!isRequestingApplication) { - return { - telemetryOptedIn: false, - }; - } - - const currentKibanaVersion = config.get('pkg.version'); - const savedObjectsClient = server.savedObjects.getScopedSavedObjectsClient(request); - const telemetrySavedObject = await getTelemetrySavedObject(savedObjectsClient); - const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ - configTelemetryAllowChangingOptInStatus, - telemetrySavedObject, - }); - - const telemetryOptedIn = getTelemetryOptIn({ - configTelemetryOptIn, - allowChangingOptInStatus, - telemetrySavedObject, - currentKibanaVersion, - }); - - const telemetrySendUsageFrom = getTelemetrySendUsageFrom({ - configTelemetrySendUsageFrom, - telemetrySavedObject, - }); - - const telemetryNotifyUserAboutOptInDefault = getNotifyUserAboutOptInDefault({ - telemetrySavedObject, - allowChangingOptInStatus, - configTelemetryOptIn, - telemetryOptedIn, - }); - - return { - telemetryOptedIn, - telemetrySendUsageFrom, - telemetryNotifyUserAboutOptInDefault, - }; -} diff --git a/src/legacy/core_plugins/ui_metric/index.ts b/src/legacy/core_plugins/ui_metric/index.ts index 5a4a0ebf1a632..2e5be3d7b0a39 100644 --- a/src/legacy/core_plugins/ui_metric/index.ts +++ b/src/legacy/core_plugins/ui_metric/index.ts @@ -25,9 +25,6 @@ export default function(kibana: any) { id: 'ui_metric', require: ['kibana', 'elasticsearch'], publicDir: resolve(__dirname, 'public'), - uiExports: { - mappings: require('./mappings.json'), - }, init() {}, }); } diff --git a/src/legacy/core_plugins/ui_metric/mappings.json b/src/legacy/core_plugins/ui_metric/mappings.json deleted file mode 100644 index 113e37e60e48b..0000000000000 --- a/src/legacy/core_plugins/ui_metric/mappings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ui-metric": { - "properties": { - "count": { - "type": "integer" - } - } - } -} diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx index 4e77bb196b713..3260e9f7d8091 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { Markdown } from '../../kibana_react/public'; +import { Markdown } from '../../../../plugins/kibana_react/public'; import { MarkdownVisParams } from './types'; interface MarkdownVisComponentProps extends MarkdownVisParams { diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js index a806339085450..d8bcf56b48cb9 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js @@ -21,7 +21,7 @@ import React from 'react'; import classNames from 'classnames'; import uuid from 'uuid'; import { get } from 'lodash'; -import { Markdown } from '../../../../../kibana_react/public'; +import { Markdown } from '../../../../../../../plugins/kibana_react/public'; import { ErrorComponent } from '../../error'; import { replaceVars } from '../../lib/replace_vars'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js index 356ba08ac2427..f559bc38b6c58 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js @@ -27,7 +27,7 @@ import { ScaleType } from '@elastic/charts'; import { createTickFormatter } from '../../lib/tick_formatter'; import { TimeSeries } from '../../../visualizations/views/timeseries'; -import { MarkdownSimple } from '../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; import { replaceVars } from '../../lib/replace_vars'; import { getAxisLabelString } from '../../lib/get_axis_label_string'; import { getInterval } from '../../lib/get_interval'; diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a24ffcbaaa49f..769d9ba311281 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -133,8 +133,8 @@ export default () => .keys({ enabled: Joi.boolean().default(false), everyBytes: Joi.number() - // > 100KB - .greater(102399) + // > 1MB + .greater(1048576) // < 1GB .less(1073741825) // 10MB diff --git a/src/legacy/server/i18n/constants.ts b/src/legacy/server/i18n/constants.ts new file mode 100644 index 0000000000000..96fa420d4c6e1 --- /dev/null +++ b/src/legacy/server/i18n/constants.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const I18N_RC = '.i18nrc.json'; + +/** + * The type name used within the Monitoring index to publish localization stats. + */ +export const KIBANA_LOCALIZATION_STATS_TYPE = 'localization'; diff --git a/src/legacy/server/i18n/get_translations_path.js b/src/legacy/server/i18n/get_translations_path.ts similarity index 85% rename from src/legacy/server/i18n/get_translations_path.js rename to src/legacy/server/i18n/get_translations_path.ts index 6ac3e75e1d4a8..ac7c61dcf8543 100644 --- a/src/legacy/server/i18n/get_translations_path.js +++ b/src/legacy/server/i18n/get_translations_path.ts @@ -24,16 +24,20 @@ import globby from 'globby'; const readFileAsync = promisify(readFile); -export async function getTranslationPaths({ cwd, glob }) { +interface I18NRCFileStructure { + translations?: string[]; +} + +export async function getTranslationPaths({ cwd, glob }: { cwd: string; glob: string }) { const entries = await globby(glob, { cwd }); - const translationPaths = []; + const translationPaths: string[] = []; for (const entry of entries) { const entryFullPath = resolve(cwd, entry); const pluginBasePath = dirname(entryFullPath); try { const content = await readFileAsync(entryFullPath, 'utf8'); - const { translations } = JSON.parse(content); + const { translations } = JSON.parse(content) as I18NRCFileStructure; if (translations && translations.length) { translations.forEach(translation => { const translationFullPath = resolve(pluginBasePath, translation); diff --git a/src/legacy/server/i18n/index.js b/src/legacy/server/i18n/index.ts similarity index 62% rename from src/legacy/server/i18n/index.js rename to src/legacy/server/i18n/index.ts index e7fa5d5f6a5c0..9902aaa1e8914 100644 --- a/src/legacy/server/i18n/index.js +++ b/src/legacy/server/i18n/index.ts @@ -19,30 +19,35 @@ import { i18n, i18nLoader } from '@kbn/i18n'; import { basename } from 'path'; +import { Server } from 'hapi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; import { getTranslationPaths } from './get_translations_path'; import { I18N_RC } from './constants'; +import KbnServer, { KibanaConfig } from '../kbn_server'; +import { registerLocalizationUsageCollector } from './localization'; -export async function i18nMixin(kbnServer, server, config) { - const locale = config.get('i18n.locale'); +export async function i18nMixin(kbnServer: KbnServer, server: Server, config: KibanaConfig) { + const locale = config.get('i18n.locale') as string; const translationPaths = await Promise.all([ getTranslationPaths({ cwd: fromRoot('.'), glob: I18N_RC, }), - ...config.get('plugins.paths').map(cwd => getTranslationPaths({ cwd, glob: I18N_RC })), - ...config - .get('plugins.scanDirs') - .map(cwd => getTranslationPaths({ cwd, glob: `*/${I18N_RC}` })), + ...(config.get('plugins.paths') as string[]).map(cwd => + getTranslationPaths({ cwd, glob: I18N_RC }) + ), + ...(config.get('plugins.scanDirs') as string[]).map(cwd => + getTranslationPaths({ cwd, glob: `*/${I18N_RC}` }) + ), getTranslationPaths({ cwd: fromRoot('../kibana-extra'), glob: `*/${I18N_RC}`, }), ]); - const currentTranslationPaths = [] + const currentTranslationPaths = ([] as string[]) .concat(...translationPaths) .filter(translationPath => basename(translationPath, '.json') === locale); i18nLoader.registerTranslationFiles(currentTranslationPaths); @@ -55,5 +60,14 @@ export async function i18nMixin(kbnServer, server, config) { }) ); - server.decorate('server', 'getTranslationsFilePaths', () => currentTranslationPaths); + const getTranslationsFilePaths = () => currentTranslationPaths; + + server.decorate('server', 'getTranslationsFilePaths', getTranslationsFilePaths); + + if (kbnServer.newPlatform.setup.plugins.usageCollection) { + registerLocalizationUsageCollector(kbnServer.newPlatform.setup.plugins.usageCollection, { + getLocale: () => config.get('i18n.locale') as string, + getTranslationsFilePaths, + }); + } } diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.mocks.ts b/src/legacy/server/i18n/localization/file_integrity.test.mocks.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.mocks.ts rename to src/legacy/server/i18n/localization/file_integrity.test.mocks.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.ts b/src/legacy/server/i18n/localization/file_integrity.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.test.ts rename to src/legacy/server/i18n/localization/file_integrity.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.ts b/src/legacy/server/i18n/localization/file_integrity.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/file_integrity.ts rename to src/legacy/server/i18n/localization/file_integrity.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/index.ts b/src/legacy/server/i18n/localization/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/index.ts rename to src/legacy/server/i18n/localization/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.test.ts b/src/legacy/server/i18n/localization/telemetry_localization_collector.test.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.test.ts rename to src/legacy/server/i18n/localization/telemetry_localization_collector.test.ts index eec5cc8a065e4..cbe23da87c767 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.test.ts +++ b/src/legacy/server/i18n/localization/telemetry_localization_collector.test.ts @@ -22,16 +22,17 @@ interface TranslationsMock { } const createI18nLoaderMock = (translations: TranslationsMock) => { - return { + return ({ getTranslationsByLocale() { return { messages: translations, }; }, - }; + } as unknown) as typeof i18nLoader; }; import { getTranslationCount } from './telemetry_localization_collector'; +import { i18nLoader } from '@kbn/i18n'; describe('getTranslationCount', () => { it('returns 0 if no translations registered', async () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.ts b/src/legacy/server/i18n/localization/telemetry_localization_collector.ts similarity index 71% rename from src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.ts rename to src/legacy/server/i18n/localization/telemetry_localization_collector.ts index 191565187be14..89566dfd4ef68 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/localization/telemetry_localization_collector.ts +++ b/src/legacy/server/i18n/localization/telemetry_localization_collector.ts @@ -19,25 +19,36 @@ import { i18nLoader } from '@kbn/i18n'; import { size } from 'lodash'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { getIntegrityHashes, Integrities } from './file_integrity'; -import { KIBANA_LOCALIZATION_STATS_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { KIBANA_LOCALIZATION_STATS_TYPE } from '../constants'; + export interface UsageStats { locale: string; integrities: Integrities; labelsCount?: number; } -export async function getTranslationCount(loader: any, locale: string): Promise { +export interface LocalizationUsageCollectorHelpers { + getLocale: () => string; + getTranslationsFilePaths: () => string[]; +} + +export async function getTranslationCount( + loader: typeof i18nLoader, + locale: string +): Promise { const translations = await loader.getTranslationsByLocale(locale); return size(translations.messages); } -export function createCollectorFetch(server: any) { +export function createCollectorFetch({ + getLocale, + getTranslationsFilePaths, +}: LocalizationUsageCollectorHelpers) { return async function fetchUsageStats(): Promise { - const config = server.config(); - const locale: string = config.get('i18n.locale'); - const translationFilePaths: string[] = server.getTranslationsFilePaths(); + const locale = getLocale(); + const translationFilePaths: string[] = getTranslationsFilePaths(); const [labelsCount, integrities] = await Promise.all([ getTranslationCount(i18nLoader, locale), @@ -54,12 +65,12 @@ export function createCollectorFetch(server: any) { export function registerLocalizationUsageCollector( usageCollection: UsageCollectionSetup, - server: any + helpers: LocalizationUsageCollectorHelpers ) { const collector = usageCollection.makeUsageCollector({ type: KIBANA_LOCALIZATION_STATS_TYPE, isReady: () => true, - fetch: createCollectorFetch(server), + fetch: createCollectorFetch(helpers), }); usageCollection.registerCollector(collector); diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 9952b345fa06f..d222dbb550f11 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -20,6 +20,7 @@ import { ResponseObject, Server } from 'hapi'; import { UnwrapPromise } from '@kbn/utility-types'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { ConfigService, CoreSetup, @@ -104,6 +105,7 @@ type KbnMixinFunc = (kbnServer: KbnServer, server: Server, config: any) => Promi export interface PluginsSetup { usageCollection: UsageCollectionSetup; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; home: HomeServerPluginSetup; [key: string]: object; } diff --git a/src/legacy/server/logging/rotate/log_rotator.test.ts b/src/legacy/server/logging/rotate/log_rotator.test.ts index c2100546364d4..70842d42f5e1f 100644 --- a/src/legacy/server/logging/rotate/log_rotator.test.ts +++ b/src/legacy/server/logging/rotate/log_rotator.test.ts @@ -204,8 +204,8 @@ describe('LogRotator', () => { expect(logRotator.running).toBe(true); expect(logRotator.usePolling).toBe(false); - const usePolling = await logRotator._shouldUsePolling(); - expect(usePolling).toBe(false); + const shouldUsePolling = await logRotator._shouldUsePolling(); + expect(shouldUsePolling).toBe(false); await logRotator.stop(); }); @@ -231,7 +231,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); }); @@ -257,7 +258,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); jest.useRealTimers(); diff --git a/src/legacy/server/logging/rotate/log_rotator.ts b/src/legacy/server/logging/rotate/log_rotator.ts index 3662910ca5a7b..eeb91fd0f2636 100644 --- a/src/legacy/server/logging/rotate/log_rotator.ts +++ b/src/legacy/server/logging/rotate/log_rotator.ts @@ -50,6 +50,7 @@ export class LogRotator { public usePolling: boolean; public pollingInterval: number; private stalkerUsePollingPolicyTestTimeout: NodeJS.Timeout | null; + public shouldUsePolling: boolean; constructor(config: KibanaConfig, server: Server) { this.config = config; @@ -64,6 +65,7 @@ export class LogRotator { this.stalker = null; this.usePolling = config.get('logging.rotate.usePolling'); this.pollingInterval = config.get('logging.rotate.pollingInterval'); + this.shouldUsePolling = false; this.stalkerUsePollingPolicyTestTimeout = null; } @@ -150,12 +152,20 @@ export class LogRotator { } async _startLogFileSizeMonitor() { - this.usePolling = await this._shouldUsePolling(); + this.usePolling = this.config.get('logging.rotate.usePolling'); + this.shouldUsePolling = await this._shouldUsePolling(); - if (this.usePolling && this.usePolling !== this.config.get('logging.rotate.usePolling')) { + if (this.usePolling && !this.shouldUsePolling) { this.log( ['warning', 'logging:rotate'], - 'The current environment does not support `fs.watch`. Falling back to polling using `fs.watchFile`' + 'Looks like your current environment support a faster algorithm then polling. You can try to disable `usePolling`' + ); + } + + if (!this.usePolling && this.shouldUsePolling) { + this.log( + ['error', 'logging:rotate'], + 'Looks like within your current environment you need to use polling in order to enable log rotator. Please enable `usePolling`' ); } diff --git a/src/legacy/server/status/index.js b/src/legacy/server/status/index.js index a9544049182a7..df02b3c45ec2f 100644 --- a/src/legacy/server/status/index.js +++ b/src/legacy/server/status/index.js @@ -57,7 +57,7 @@ export function statusMixin(kbnServer, server, config) { // init routes registerStatusPage(kbnServer, server, config); registerStatusApi(kbnServer, server, config); - registerStatsApi(usageCollection, server, config); + registerStatsApi(usageCollection, server, config, kbnServer); // expore shared functionality server.decorate('server', 'getOSInfo', getOSInfo); diff --git a/src/legacy/server/status/routes/api/register_stats.js b/src/legacy/server/status/routes/api/register_stats.js index e218c1caf1701..2dd66cb8caff7 100644 --- a/src/legacy/server/status/routes/api/register_stats.js +++ b/src/legacy/server/status/routes/api/register_stats.js @@ -21,7 +21,7 @@ import Joi from 'joi'; import boom from 'boom'; import { i18n } from '@kbn/i18n'; import { wrapAuthConfig } from '../../wrap_auth_config'; -import { KIBANA_STATS_TYPE } from '../../constants'; +import { getKibanaInfoForStats } from '../../lib'; const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { defaultMessage: 'Stats are not ready yet. Please try again later.', @@ -37,7 +37,7 @@ const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { * - Any other value causes a statusCode 400 response (Bad Request) * Including ?exclude_usage in the query string excludes the usage stats from the response. Same value semantics as ?extended */ -export function registerStatsApi(usageCollection, server, config) { +export function registerStatsApi(usageCollection, server, config, kbnServer) { const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous')); const getClusterUuid = async callCluster => { @@ -50,6 +50,17 @@ export function registerStatsApi(usageCollection, server, config) { return usageCollection.toObject(usage); }; + let lastMetrics = null; + /* kibana_stats gets singled out from the collector set as it is used + * for health-checking Kibana and fetch does not rely on fetching data + * from ES */ + server.newPlatform.setup.core.metrics.getOpsMetrics$().subscribe(metrics => { + lastMetrics = { + ...metrics, + timestamp: new Date().toISOString(), + }; + }); + server.route( wrapAuth({ method: 'GET', @@ -133,15 +144,15 @@ export function registerStatsApi(usageCollection, server, config) { } } - /* kibana_stats gets singled out from the collector set as it is used - * for health-checking Kibana and fetch does not rely on fetching data - * from ES */ - const kibanaStatsCollector = usageCollection.getCollectorByType(KIBANA_STATS_TYPE); - if (!(await kibanaStatsCollector.isReady())) { + if (!lastMetrics) { return boom.serverUnavailable(STATS_NOT_READY_MESSAGE); } - let kibanaStats = await kibanaStatsCollector.fetch(); - kibanaStats = usageCollection.toApiFieldNames(kibanaStats); + const kibanaStats = usageCollection.toApiFieldNames({ + ...lastMetrics, + kibana: getKibanaInfoForStats(server, kbnServer), + last_updated: new Date().toISOString(), + collection_interval_in_millis: config.get('ops.interval'), + }); return { ...kibanaStats, diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 3c3067776a161..87006d9347de4 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -15,7 +15,6 @@ @import './error_url_overflow/index'; @import './exit_full_screen/index'; @import './field_editor/index'; -@import './share/index'; @import './style_compile/index'; @import '../../../plugins/management/public/components/index'; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index c58a7d2fbb5cd..67877c5382633 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -271,6 +271,12 @@ export const npSetup = { }), }, }, + discover: { + docViews: { + addDocView: sinon.fake(), + setAngularInjectorGetter: sinon.fake(), + }, + }, visTypeVega: { config: sinon.fake(), }, @@ -324,6 +330,9 @@ export const npStart = { getHideWriteControls: sinon.fake(), }, }, + dashboard: { + getSavedDashboardLoader: sinon.fake(), + }, data: { actions: { createFiltersFromEvent: Promise.resolve(['yes']), @@ -459,6 +468,11 @@ export const npStart = { useChartsTheme: sinon.fake(), }, }, + discover: { + docViews: { + DocViewer: () => null, + }, + }, }, }; diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index deb8387fee29c..b315abec1a64b 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -22,6 +22,7 @@ import { IScope } from 'angular'; import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; import { createBrowserHistory } from 'history'; +import { DashboardStart } from '../../../../plugins/dashboard/public'; import { LegacyCoreSetup, LegacyCoreStart, @@ -65,6 +66,7 @@ import { NavigationPublicPluginStart, } from '../../../../plugins/navigation/public'; import { VisTypeVegaSetup } from '../../../../plugins/vis_type_vega/public'; +import { DiscoverSetup, DiscoverStart } from '../../../../plugins/discover/public'; export interface PluginsSetup { bfetch: BfetchPublicSetup; @@ -83,6 +85,7 @@ export interface PluginsSetup { advancedSettings: AdvancedSettingsSetup; management: ManagementSetup; visTypeVega: VisTypeVegaSetup; + discover: DiscoverSetup; telemetry?: TelemetryPluginSetup; } @@ -100,7 +103,9 @@ export interface PluginsStart { share: SharePluginStart; management: ManagementStart; advancedSettings: AdvancedSettingsStart; + discover: DiscoverStart; telemetry?: TelemetryPluginStart; + dashboard: DashboardStart; } export const npSetup = { diff --git a/src/plugins/advanced_settings/public/management_app/index.tsx b/src/plugins/advanced_settings/public/management_app/index.tsx index 27d3114051c16..53b8f9983aa27 100644 --- a/src/plugins/advanced_settings/public/management_app/index.tsx +++ b/src/plugins/advanced_settings/public/management_app/index.tsx @@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { AdvancedSettings } from './advanced_settings'; import { ManagementSetup } from '../../../management/public'; -import { CoreSetup } from '../../../../core/public'; +import { StartServicesAccessor } from '../../../../core/public'; import { ComponentRegistry } from '../types'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { @@ -48,7 +48,7 @@ export async function registerAdvSettingsMgmntApp({ componentRegistry, }: { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; componentRegistry: ComponentRegistry['start']; }) { const kibanaSection = management.sections.getSection('kibana'); diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json index 2135bd67e57d8..40b0e56782641 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json @@ -6,7 +6,14 @@ "h": [], "help": "__flag__", "s": [], - "v": "__flag__" + "v": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] }, "methods": [ "GET" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json index e6ca1fb575396..410350df13721 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json @@ -36,7 +36,14 @@ "nanos" ], "v": "__flag__", - "include_unloaded_segments": "__flag__" + "include_unloaded_segments": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] }, "methods": [ "GET" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json new file mode 100644 index 0000000000000..e935b8999e6d3 --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json @@ -0,0 +1,15 @@ +{ + "cluster.delete_component_template": { + "url_params": { + "timeout": "", + "master_timeout": "" + }, + "methods": [ + "DELETE" + ], + "patterns": [ + "_component_template/{name}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-templates.html" + } +} diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json index 64ede603c0e0d..1758ea44d92c0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json @@ -4,6 +4,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json index ba9c8d427e7bd..fb4a02c603174 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json @@ -11,6 +11,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/count.json b/src/plugins/console/server/lib/spec_definitions/json/generated/count.json index bd69fd0c77ec8..67386eb7c6f1b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/count.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/count.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json index 2d1636d5f2c02..e01ea8b2dec6d 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json @@ -1,6 +1,7 @@ { "delete_by_query": { "url_params": { + "analyzer": "", "analyze_wildcard": "__flag__", "default_operator": [ "AND", @@ -17,6 +18,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json b/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json index 5e632018bef25..4bf63d7566788 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json index f5cf05c9a3f7f..fc84d07df88a4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json index 676f20632e63b..1b58a27829bc7 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json index 8227e38d3c6d9..1970f88b30958 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json @@ -1,6 +1,7 @@ { "indices.create": { "url_params": { + "include_type_name": "__flag__", "wait_for_active_shards": "", "timeout": "", "master_timeout": "" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json index b006d5ea7a3cb..084828108123b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json index 33c845210ea87..09f6c7fd780f8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json index d302bbe6b93de..4b93184ed52f1 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json index 70d35e6c453c9..0b11356155b50 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json index 0ad1a250229b2..63c86d10a9864 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json index 0e705e2e721ee..b642d5f04a044 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json index 7ca9e88274aa5..6df796ed6c4cf 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json @@ -1,12 +1,14 @@ { "indices.get": { "url_params": { + "include_type_name": "__flag__", "local": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json index d687cab56630f..95bc74edc5865 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json index ea952435566ed..c95e2efc73fab 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json @@ -1,12 +1,14 @@ { "indices.get_field_mapping": { "url_params": { + "include_type_name": "__flag__", "include_defaults": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json index 73f4e42262bf2..555137d0e2ee0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json @@ -1,11 +1,13 @@ { "indices.get_mapping": { "url_params": { + "include_type_name": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json index 1c84258d0fce9..a6777f7a820aa 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json index f5902929c25cc..d5f52ec76b374 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json @@ -1,6 +1,7 @@ { "indices.get_template": { "url_params": { + "include_type_name": "__flag__", "flat_settings": "__flag__", "master_timeout": "", "local": "__flag__" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json index d781172c54d63..99ac958523084 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json index b5c4c5501d05d..6369238739203 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json index 07a62a64b64e1..e36783c815e3f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json @@ -1,6 +1,7 @@ { "indices.put_mapping": { "url_params": { + "include_type_name": "__flag__", "timeout": "", "master_timeout": "", "ignore_unavailable": "__flag__", @@ -8,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json index fe7b938d2f3fc..a2508cd0fc817 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json index 54a7625a2713c..e6317bd6eb537 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json @@ -1,11 +1,10 @@ { "indices.put_template": { "url_params": { + "include_type_name": "__flag__", "order": "", "create": "__flag__", - "timeout": "", - "master_timeout": "", - "flat_settings": "__flag__" + "master_timeout": "" }, "methods": [ "PUT", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json index 54cd2a869902a..2906349d3fdae 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json index 19e0f1f909ab8..7fa76a687eb77 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json @@ -1,6 +1,7 @@ { "indices.rollover": { "url_params": { + "include_type_name": "__flag__", "timeout": "", "dry_run": "__flag__", "master_timeout": "", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json index 9e2eb6efce27e..b3c07150699af 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json index f8e026eb89984..c50f4cf501698 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json index c3fc0f8f7055f..1fa32265c91ee 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json @@ -16,6 +16,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json index 68ee06dd1b0bd..484115bb9b260 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json @@ -5,6 +5,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json index 33720576ef8a3..315aa13d4b4e8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json index c2f741066bbdb..0b0ca087b1819 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json @@ -9,7 +9,8 @@ ], "typed_keys": "__flag__", "max_concurrent_searches": "", - "rest_total_hits_as_int": "__flag__" + "rest_total_hits_as_int": "__flag__", + "ccs_minimize_roundtrips": "__flag__" }, "methods": [ "GET", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json b/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json index c2bed081124a8..4d73e58bd4c06 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search.json index eb21b43644d77..78b969d3ed8f2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search.json @@ -19,6 +19,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json index cbeb0a429352d..b0819f8e066c8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json index cf5a5c5f32db3..748326522e5c2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], @@ -22,7 +23,8 @@ "explain": "__flag__", "profile": "__flag__", "typed_keys": "__flag__", - "rest_total_hits_as_int": "__flag__" + "rest_total_hits_as_int": "__flag__", + "ccs_minimize_roundtrips": "__flag__" }, "methods": [ "GET", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json index 393197949e86c..596f8f8b83963 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json @@ -18,6 +18,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json index 7e1655e680b8f..949b897b29ff4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json +++ b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json @@ -1,11 +1,38 @@ { "cluster.health": { "url_params": { - "master_timeout": "30s", - "timeout": "30s", - "wait_for_relocating_shards": 0, - "wait_for_active_shards": 0, - "wait_for_nodes": 0 + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ], + "level": [ + "cluster", + "indices", + "shards" + ], + "local": "__flag__", + "master_timeout": "", + "timeout": "", + "wait_for_active_shards": "", + "wait_for_nodes": "", + "wait_for_events": [ + "immediate", + "urgent", + "high", + "normal", + "low", + "languid" + ], + "wait_for_no_relocating_shards": "__flag__", + "wait_for_no_initializing_shards": "__flag__", + "wait_for_status": [ + "green", + "yellow", + "red" + ] } } } diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json deleted file mode 100644 index e0cbcc9cee2ec..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "indices.get_template": { - "patterns": [ - "_template", - "_template/{template}" - ] - } -} diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index e5a657555819a..e35599a5f0b66 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -5,7 +5,8 @@ "data", "embeddable", "inspector", - "uiActions" + "uiActions", + "savedObjects" ], "optionalPlugins": [ "share" diff --git a/src/legacy/core_plugins/telemetry/public/views/management/index.ts b/src/plugins/dashboard/public/bwc/index.ts similarity index 96% rename from src/legacy/core_plugins/telemetry/public/views/management/index.ts rename to src/plugins/dashboard/public/bwc/index.ts index 2e9f064ec80d8..d8f7b5091eb8f 100644 --- a/src/legacy/core_plugins/telemetry/public/views/management/index.ts +++ b/src/plugins/dashboard/public/bwc/index.ts @@ -17,4 +17,4 @@ * under the License. */ -import './management'; +export * from './types'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts b/src/plugins/dashboard/public/bwc/types.ts similarity index 91% rename from src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts rename to src/plugins/dashboard/public/bwc/types.ts index c264358a8f81f..e9b9d392e9b7d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts +++ b/src/plugins/dashboard/public/bwc/types.ts @@ -17,8 +17,27 @@ * under the License. */ -import { GridData } from '../np_ready/types'; -import { Doc, DocPre700 } from '../../../migrations/types'; +import { SavedObjectReference } from 'kibana/public'; +import { GridData } from '../../../../plugins/dashboard/public'; + +export interface SavedObjectAttributes { + kibanaSavedObjectMeta: { + searchSourceJSON: string; + }; +} + +export interface Doc { + references: SavedObjectReference[]; + attributes: Attributes; + id: string; + type: string; +} + +export interface DocPre700 { + attributes: Attributes; + id: string; + type: string; +} export interface SavedObjectAttributes { kibanaSavedObjectMeta: { diff --git a/src/legacy/core_plugins/application_usage/index.ts b/src/plugins/dashboard/public/dashboard_constants.ts similarity index 66% rename from src/legacy/core_plugins/application_usage/index.ts rename to src/plugins/dashboard/public/dashboard_constants.ts index 752d6eaa19bb0..0820ebd371004 100644 --- a/src/legacy/core_plugins/application_usage/index.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -17,15 +17,16 @@ * under the License. */ -import { Legacy } from '../../../../kibana'; -import { mappings } from './mappings'; +export const DashboardConstants = { + ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', + LANDING_PAGE_PATH: '/dashboards', + CREATE_NEW_DASHBOARD_URL: '/dashboard', + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + DASHBOARDS_ID: 'dashboards', + DASHBOARD_ID: 'dashboard', +}; -// eslint-disable-next-line import/no-default-export -export default function ApplicationUsagePlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'application_usage', - uiExports: { mappings }, // Needed to define the mappings for the SavedObjects - }; - - return new kibana.Plugin(config); +export function createDashboardEditUrl(id: string) { + return `/dashboard/${id}`; } diff --git a/src/plugins/dashboard/public/embeddable/index.ts b/src/plugins/dashboard/public/embeddable/index.ts index 58bfd5eedefcb..fcc5fe5202bd2 100644 --- a/src/plugins/dashboard/public/embeddable/index.ts +++ b/src/plugins/dashboard/public/embeddable/index.ts @@ -21,7 +21,7 @@ export { DashboardContainerFactory } from './dashboard_container_factory'; export { DashboardContainer, DashboardContainerInput } from './dashboard_container'; export { createPanelState } from './panel'; -export { DashboardPanelState, GridData } from './types'; +export * from './types'; export { DASHBOARD_GRID_COLUMN_COUNT, diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index c6846346b64ef..070e437ce52ef 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -22,14 +22,43 @@ import './index.scss'; import { PluginInitializerContext } from '../../../core/public'; import { DashboardEmbeddableContainerPublicPlugin } from './plugin'; -export * from './types'; -export * from './actions'; -export * from './embeddable'; +/** + * These types can probably be internal once all of dashboard app is migrated into this plugin. Right + * now, migrations are still in legacy land. + */ +export { + DashboardDoc730ToLatest, + DashboardDoc700To720, + RawSavedDashboardPanelTo60, + RawSavedDashboardPanel610, + RawSavedDashboardPanel620, + RawSavedDashboardPanel630, + RawSavedDashboardPanel640To720, + RawSavedDashboardPanel730ToLatest, + DashboardDocPre700, +} from './bwc'; -export function plugin(initializerContext: PluginInitializerContext) { - return new DashboardEmbeddableContainerPublicPlugin(initializerContext); -} +export {} from './types'; +export {} from './actions'; +export { + DashboardContainer, + DashboardContainerInput, + DashboardContainerFactory, + DASHBOARD_CONTAINER_TYPE, + DashboardPanelState, + // Types below here can likely be made private when dashboard app moved into this NP plugin. + DEFAULT_PANEL_WIDTH, + DEFAULT_PANEL_HEIGHT, + GridData, +} from './embeddable'; + +export { SavedObjectDashboard } from './saved_dashboards'; +export { DashboardStart } from './plugin'; export { DashboardEmbeddableContainerPublicPlugin as Plugin }; export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new DashboardEmbeddableContainerPublicPlugin(initializerContext); +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index d663c736e5aed..3d67435e6d8f7 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -21,13 +21,18 @@ import * as React from 'react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { SharePluginSetup } from 'src/plugins/share/public'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableSetup, + EmbeddableStart, +} from '../../../plugins/embeddable/public'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; +import { SharePluginSetup } from '../../../plugins/share/public'; import { UiActionsSetup, UiActionsStart } from '../../../plugins/ui_actions/public'; -import { CONTEXT_MENU_TRIGGER, EmbeddableSetup, EmbeddableStart } from './embeddable_plugin'; -import { ExpandPanelAction, ReplacePanelAction } from '.'; +import { ExpandPanelAction, ReplacePanelAction } from './actions'; import { DashboardContainerFactory } from './embeddable/dashboard_container_factory'; import { Start as InspectorStartContract } from '../../../plugins/inspector/public'; -import { getSavedObjectFinder } from '../../../plugins/saved_objects/public'; +import { getSavedObjectFinder, SavedObjectLoader } from '../../../plugins/saved_objects/public'; import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, @@ -39,6 +44,7 @@ import { DASHBOARD_APP_URL_GENERATOR, createDirectAccessDashboardLinkGenerator, } from './url_generator'; +import { createSavedDashboardLoader } from './saved_dashboards'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -56,10 +62,13 @@ interface StartDependencies { embeddable: EmbeddableStart; inspector: InspectorStartContract; uiActions: UiActionsStart; + data: DataPublicPluginStart; } export type Setup = void; -export type Start = void; +export interface DashboardStart { + getSavedDashboardLoader: () => SavedObjectLoader; +} declare module '../../../plugins/ui_actions/public' { export interface ActionContextMapping { @@ -69,7 +78,7 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardEmbeddableContainerPublicPlugin - implements Plugin { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} public setup( @@ -121,9 +130,12 @@ export class DashboardEmbeddableContainerPublicPlugin embeddable.registerEmbeddableFactory(factory.type, factory); } - public start(core: CoreStart, plugins: StartDependencies): Start { + public start(core: CoreStart, plugins: StartDependencies): DashboardStart { const { notifications } = core; - const { uiActions } = plugins; + const { + uiActions, + data: { indexPatterns }, + } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); @@ -135,6 +147,15 @@ export class DashboardEmbeddableContainerPublicPlugin ); uiActions.registerAction(changeViewAction); uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); + const savedDashboardLoader = createSavedDashboardLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns, + chrome: core.chrome, + overlays: core.overlays, + }); + return { + getSavedDashboardLoader: () => savedDashboardLoader, + }; } public stop() {} diff --git a/src/plugins/dashboard/public/saved_dashboards/index.ts b/src/plugins/dashboard/public/saved_dashboards/index.ts new file mode 100644 index 0000000000000..9b7745bd884f7 --- /dev/null +++ b/src/plugins/dashboard/public/saved_dashboards/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export * from './saved_dashboard_references'; +export * from './saved_dashboard'; +export * from './saved_dashboards'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts similarity index 86% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index c5ac05b5a77eb..c4ebf4f07a5db 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -20,16 +20,11 @@ import { createSavedObjectClass, SavedObject, SavedObjectKibanaServices, -} from '../../../../../../plugins/saved_objects/public'; +} from '../../../../plugins/saved_objects/public'; import { extractReferences, injectReferences } from './saved_dashboard_references'; -import { - Filter, - ISearchSource, - Query, - RefreshInterval, -} from '../../../../../../plugins/data/public'; -import { createDashboardEditUrl } from '..'; +import { Filter, ISearchSource, Query, RefreshInterval } from '../../../../plugins/data/public'; +import { createDashboardEditUrl } from '../dashboard_constants'; export interface SavedObjectDashboard extends SavedObject { id?: string; @@ -49,7 +44,9 @@ export interface SavedObjectDashboard extends SavedObject { } // Used only by the savedDashboards service, usually no reason to change this -export function createSavedDashboardClass(services: SavedObjectKibanaServices) { +export function createSavedDashboardClass( + services: SavedObjectKibanaServices +): new (id: string) => SavedObjectDashboard { const SavedObjectClass = createSavedObjectClass(services); class SavedDashboard extends SavedObjectClass { // save these objects with the 'dashboard' type @@ -121,5 +118,7 @@ export function createSavedDashboardClass(services: SavedObjectKibanaServices) { } } - return SavedDashboard; + // Unfortunately this throws a typescript error without the casting. I think it's due to the + // convoluted way SavedObjects are created. + return (SavedDashboard as unknown) as new (id: string) => SavedObjectDashboard; } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.test.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts similarity index 67% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts index 2ff76da9c5ca6..2a1e64fa88a02 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts @@ -17,13 +17,22 @@ * under the License. */ -import { - SavedObjectLoader, - SavedObjectKibanaServices, -} from '../../../../../../plugins/saved_objects/public'; +import { SavedObjectsClientContract, ChromeStart, OverlayStart } from 'kibana/public'; +import { IndexPatternsContract } from '../../../../plugins/data/public'; +import { SavedObjectLoader } from '../../../../plugins/saved_objects/public'; import { createSavedDashboardClass } from './saved_dashboard'; -export function createSavedDashboardLoader(services: SavedObjectKibanaServices) { +interface Services { + savedObjectsClient: SavedObjectsClientContract; + indexPatterns: IndexPatternsContract; + chrome: ChromeStart; + overlays: OverlayStart; +} + +/** + * @param services + */ +export function createSavedDashboardLoader(services: Services) { const SavedDashboard = createSavedDashboardClass(services); return new SavedObjectLoader(SavedDashboard, services.savedObjectsClient, services.chrome); } diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index b27f899e52523..cb6190e81a778 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -376,6 +376,8 @@ export { TabbedAggColumn, TabbedAggRow, TabbedTable, + SearchInterceptor, + RequestTimeoutError, } from './search'; // Search namespace diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index c4c91df634d24..e95765b37e6fb 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -7,6 +7,7 @@ import { $Values } from '@kbn/utility-types'; import _ from 'lodash'; import { Action } from 'history'; +import { ApplicationStart } from 'kibana/public'; import { Assign } from '@kbn/utility-types'; import { Breadcrumb } from '@elastic/eui'; import { Component } from 'react'; @@ -49,6 +50,9 @@ import { SavedObjectsClientContract } from 'src/core/public'; import { SearchParams } from 'elasticsearch'; import { SearchResponse as SearchResponse_2 } from 'elasticsearch'; import { SimpleSavedObject } from 'src/core/public'; +import { Subscription } from 'rxjs'; +import { Toast } from 'kibana/public'; +import { ToastsStart } from 'kibana/public'; import { UiActionsSetup } from 'src/plugins/ui_actions/public'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { Unit } from '@elastic/datemath'; @@ -1469,6 +1473,13 @@ export interface RefreshInterval { value: number; } +// Warning: (ae-missing-release-tag) "RequestTimeoutError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class RequestTimeoutError extends Error { + constructor(message?: string); +} + // Warning: (ae-missing-release-tag) "SavedQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1586,6 +1597,28 @@ export class SearchError extends Error { type: string; } +// Warning: (ae-missing-release-tag) "SearchInterceptor" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class SearchInterceptor { + constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); + protected abortController: AbortController; + // (undocumented) + protected readonly application: ApplicationStart; + getPendingCount$: () => import("rxjs").Observable; + // (undocumented) + protected hideToast: () => void; + protected longRunningToast?: Toast; + // (undocumented) + protected readonly requestTimeout?: number | undefined; + search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; + // (undocumented) + protected showToast: () => void; + protected timeoutSubscriptions: Set; + // (undocumented) + protected readonly toasts: ToastsStart; +} + // Warning: (ae-missing-release-tag) "SearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1853,21 +1886,21 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:404:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:414:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:56:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index f3d2d99af5998..1687d749f46e2 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -57,5 +57,6 @@ export { } from './search_source'; export { SearchInterceptor } from './search_interceptor'; +export { RequestTimeoutError } from './request_timeout_error'; export { FetchOptions } from './fetch'; diff --git a/src/plugins/data/public/search/long_query_notification.tsx b/src/plugins/data/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..590fee20db690 --- /dev/null +++ b/src/plugins/data/public/search/long_query_notification.tsx @@ -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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { ApplicationStart } from 'kibana/public'; +import { toMountPoint } from '../../../kibana_react/public'; + +interface Props { + application: ApplicationStart; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint(); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + { + await props.application.navigateToApp( + 'kibana#/management/elasticsearch/license_management' + ); + }} + > + + + + +
+ ); +} diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 12cf258759a99..b70e889066a45 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -31,10 +31,8 @@ export const searchSetupMock = { export const searchStartMock: jest.Mocked = { aggs: searchAggsStartMock(), + setInterceptor: jest.fn(), search: jest.fn(), - cancel: jest.fn(), - getPendingCount$: jest.fn(), - runBeyondTimeout: jest.fn(), __LEGACY: { AggConfig: jest.fn() as any, AggType: jest.fn(), diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index a89d17464b9e0..bd056271688c1 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -18,27 +18,38 @@ */ import { Observable, Subject } from 'rxjs'; +import { CoreStart } from '../../../../core/public'; +import { coreMock } from '../../../../core/public/mocks'; import { IKibanaSearchRequest } from '../../common/search'; import { RequestTimeoutError } from './request_timeout_error'; import { SearchInterceptor } from './search_interceptor'; jest.useFakeTimers(); -const flushPromises = () => new Promise(resolve => setImmediate(resolve)); const mockSearch = jest.fn(); let searchInterceptor: SearchInterceptor; +let mockCoreStart: MockedKeys; describe('SearchInterceptor', () => { beforeEach(() => { + mockCoreStart = coreMock.createStart(); mockSearch.mockClear(); - searchInterceptor = new SearchInterceptor(1000); + searchInterceptor = new SearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); }); describe('search', () => { test('should invoke `search` with the request', () => { - mockSearch.mockReturnValue(new Observable()); + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); const mockRequest: IKibanaSearchRequest = {}; - searchInterceptor.search(mockSearch, mockRequest); + const response = searchInterceptor.search(mockSearch, mockRequest); + mockResponse.complete(); + + response.subscribe(); expect(mockSearch.mock.calls[0][0]).toBe(mockRequest); }); @@ -92,44 +103,6 @@ describe('SearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockSearch.mockReturnValue(new Observable()); - - searchInterceptor.search(mockSearch, {}); - searchInterceptor.search(mockSearch, {}); - searchInterceptor.cancelPending(); - - await flushPromises(); - - const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); - expect(areAllRequestsAborted).toBe(true); - }); - }); - - describe('runBeyondTimeout', () => { - test('should prevent the request from timing out', () => { - const mockResponse = new Subject(); - mockSearch.mockReturnValue(mockResponse.asObservable()); - const response = searchInterceptor.search(mockSearch, {}); - - setTimeout(searchInterceptor.runBeyondTimeout, 500); - setTimeout(() => mockResponse.next('hi'), 250); - setTimeout(() => mockResponse.complete(), 2000); - - const next = jest.fn(); - const complete = jest.fn(); - const error = jest.fn(); - response.subscribe({ next, error, complete }); - - jest.advanceTimersByTime(2000); - - expect(next).toHaveBeenCalledWith('hi'); - expect(error).not.toHaveBeenCalled(); - expect(complete).toHaveBeenCalled(); - }); - }); - describe('getPendingCount$', () => { test('should observe the number of pending requests', () => { let i = 0; diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3f83214f6050c..d83ddab807bc5 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -17,51 +17,59 @@ * under the License. */ -import { BehaviorSubject, fromEvent, throwError } from 'rxjs'; -import { mergeMap, takeUntil, finalize } from 'rxjs/operators'; +import { BehaviorSubject, throwError, timer, Subscription, defer, fromEvent } from 'rxjs'; +import { takeUntil, finalize, filter, mergeMapTo } from 'rxjs/operators'; +import { ApplicationStart, Toast, ToastsStart } from 'kibana/public'; import { getCombinedSignal } from '../../common/utils'; import { IKibanaSearchRequest } from '../../common/search'; import { ISearchGeneric, ISearchOptions } from './i_search'; import { RequestTimeoutError } from './request_timeout_error'; +import { getLongQueryNotification } from './long_query_notification'; export class SearchInterceptor { /** * `abortController` used to signal all searches to abort. */ - private abortController = new AbortController(); + protected abortController = new AbortController(); /** - * Observable that emits when the number of pending requests changes. + * The number of pending search requests. */ - private pendingCount$ = new BehaviorSubject(0); + private pendingCount = 0; /** - * The IDs from `setTimeout` when scheduling the automatic timeout for each request. + * Observable that emits when the number of pending requests changes. */ - private timeoutIds: Set = new Set(); + private pendingCount$ = new BehaviorSubject(this.pendingCount); /** - * This class should be instantiated with a `requestTimeout` corresponding with how many ms after - * requests are initiated that they should automatically cancel. - * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + * The subscriptions from scheduling the automatic timeout for each request. */ - constructor(private readonly requestTimeout?: number) {} + protected timeoutSubscriptions: Set = new Set(); /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. + * The current long-running toast (if there is one). */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - }; + protected longRunningToast?: Toast; /** - * Un-schedule timing out all of the searches intercepted. + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` */ - public runBeyondTimeout = () => { - this.timeoutIds.forEach(clearTimeout); - this.timeoutIds.clear(); - }; + constructor( + protected readonly toasts: ToastsStart, + protected readonly application: ApplicationStart, + protected readonly requestTimeout?: number + ) { + // When search requests go out, a notification is scheduled allowing users to continue the + // request past the timeout. When all search requests complete, we remove the notification. + this.getPendingCount$() + .pipe(filter(count => count === 0)) + .subscribe(this.hideToast); + } /** * Returns an `Observable` over the current number of pending searches. This could mean that one @@ -81,41 +89,66 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ) => { - // Schedule this request to automatically timeout after some interval - const timeoutController = new AbortController(); - const { signal: timeoutSignal } = timeoutController; - const timeoutId = window.setTimeout(() => { - timeoutController.abort(); - }, this.requestTimeout); - this.addTimeoutId(timeoutId); - - // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) - // 2. The request times out - // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) - const signals = [this.abortController.signal, timeoutSignal, options?.signal].filter( - Boolean - ) as AbortSignal[]; - const combinedSignal = getCombinedSignal(signals); - - // If the request timed out, throw a `RequestTimeoutError` - const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( - mergeMap(() => throwError(new RequestTimeoutError())) - ); + // Defer the following logic until `subscribe` is actually called + return defer(() => { + this.pendingCount$.next(++this.pendingCount); - return search(request as any, { ...options, signal: combinedSignal }).pipe( - takeUntil(timeoutError$), - finalize(() => this.removeTimeoutId(timeoutId)) - ); + // Schedule this request to automatically timeout after some interval + const timeoutController = new AbortController(); + const { signal: timeoutSignal } = timeoutController; + const timeout$ = timer(this.requestTimeout); + const subscription = timeout$.subscribe(() => timeoutController.abort()); + this.timeoutSubscriptions.add(subscription); + + // If the request timed out, throw a `RequestTimeoutError` + const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( + mergeMapTo(throwError(new RequestTimeoutError())) + ); + + // Schedule the notification to allow users to cancel or wait beyond the timeout + const notificationSubscription = timer(10000).subscribe(this.showToast); + + // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: + // 1. The user manually aborts (via `cancelPending`) + // 2. The request times out + // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) + const signals = [ + this.abortController.signal, + timeoutSignal, + ...(options?.signal ? [options.signal] : []), + ]; + const combinedSignal = getCombinedSignal(signals); + + return search(request as any, { ...options, signal: combinedSignal }).pipe( + takeUntil(timeoutError$), + finalize(() => { + this.pendingCount$.next(--this.pendingCount); + this.timeoutSubscriptions.delete(subscription); + notificationSubscription.unsubscribe(); + }) + ); + }); }; - private addTimeoutId(id: number) { - this.timeoutIds.add(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + application: this.application, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; - private removeTimeoutId(id: number) { - this.timeoutIds.delete(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected hideToast = () => { + if (this.longRunningToast) { + this.toasts.remove(this.longRunningToast); + delete this.longRunningToast; + } + }; } diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 62c7e0468bb88..311a8a2fc6f60 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -58,6 +58,7 @@ export class SearchService implements Plugin { private esClient?: LegacyApiCaller; private readonly aggTypesRegistry = new AggTypesRegistry(); + private searchInterceptor!: SearchInterceptor; private registerSearchStrategyProvider = ( name: T, @@ -98,7 +99,9 @@ export class SearchService implements Plugin { * TODO: Make this modular so that apps can opt in/out of search collection, or even provide * their own search collector instances */ - const searchInterceptor = new SearchInterceptor( + this.searchInterceptor = new SearchInterceptor( + core.notifications.toasts, + core.application, core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); @@ -114,16 +117,17 @@ export class SearchService implements Plugin { }, types: aggTypesStart, }, - cancel: () => searchInterceptor.cancelPending(), - getPendingCount$: () => searchInterceptor.getPendingCount$(), - runBeyondTimeout: () => searchInterceptor.runBeyondTimeout(), search: (request, options, strategyName) => { const strategyProvider = this.getSearchStrategy(strategyName || DEFAULT_SEARCH_STRATEGY); const { search } = strategyProvider({ core, getSearchStrategy: this.getSearchStrategy, }); - return searchInterceptor.search(search as any, request, options); + return this.searchInterceptor.search(search as any, request, options); + }, + setInterceptor: (searchInterceptor: SearchInterceptor) => { + // TODO: should an intercepror have a destroy method? + this.searchInterceptor = searchInterceptor; }, __LEGACY: { esClient: this.esClient!, diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 1b551f978b971..03cbfa9f8ed84 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -17,12 +17,12 @@ * under the License. */ -import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs'; import { ISearch, ISearchGeneric } from './i_search'; import { TStrategyTypes } from './strategy_types'; import { LegacyApiCaller } from './es_client'; +import { SearchInterceptor } from './search_interceptor'; export interface ISearchContext { core: CoreStart; @@ -87,9 +87,7 @@ export interface ISearchSetup { export interface ISearchStart { aggs: SearchAggsStart; - cancel: () => void; - getPendingCount$: () => Observable; - runBeyondTimeout: () => void; + setInterceptor: (searchInterceptor: SearchInterceptor) => void; search: ISearchGeneric; __LEGACY: ISearchStartLegacy & SearchAggsStartLegacy; } diff --git a/src/plugins/data/server/kql_telemetry/route.ts b/src/plugins/data/server/kql_telemetry/route.ts index d5725c859c9a9..dd7ff333e6257 100644 --- a/src/plugins/data/server/kql_telemetry/route.ts +++ b/src/plugins/data/server/kql_telemetry/route.ts @@ -17,12 +17,12 @@ * under the License. */ -import { CoreSetup, IRouter, Logger } from 'kibana/server'; +import { StartServicesAccessor, IRouter, Logger } from 'kibana/server'; import { schema } from '@kbn/config-schema'; export function registerKqlTelemetryRoute( router: IRouter, - getStartServices: CoreSetup['getStartServices'], + getStartServices: StartServicesAccessor, logger: Logger ) { router.post( diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 5c231cdc05e61..1abc74fe07ccc 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -7,7 +7,6 @@ import { APICaller as APICaller_2 } from 'kibana/server'; import Boom from 'boom'; import { BulkIndexDocumentsParams } from 'elasticsearch'; -import { CallCluster as CallCluster_2 } from 'src/legacy/core_plugins/elasticsearch'; import { CatAliasesParams } from 'elasticsearch'; import { CatAllocationParams } from 'elasticsearch'; import { CatCommonParams } from 'elasticsearch'; diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 9ebfeb5387b26..df61271baf879 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -132,4 +132,6 @@ export class DevToolsPlugin implements Plugin { getSortedDevTools: this.getSortedDevTools.bind(this), }; } + + public stop() {} } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json new file mode 100644 index 0000000000000..91d6358d44c18 --- /dev/null +++ b/src/plugins/discover/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "discover", + "version": "kibana", + "server": false, + "ui": true +} diff --git a/src/plugins/discover/public/components/_index.scss b/src/plugins/discover/public/components/_index.scss new file mode 100644 index 0000000000000..ff50d4b5dca93 --- /dev/null +++ b/src/plugins/discover/public/components/_index.scss @@ -0,0 +1 @@ +@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss b/src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss rename to src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss b/src/plugins/discover/public/components/doc_viewer/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss rename to src/plugins/discover/public/components/doc_viewer/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx similarity index 81% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx index 15f0f40700abc..6f29f10ddd026 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx @@ -21,37 +21,33 @@ import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; -jest.mock('../../../kibana_services', () => { +jest.mock('../../services', () => { let registry: any[] = []; return { - getServices: () => ({ - docViewsRegistry: { - addDocView(view: any) { - registry.push(view); - }, - getDocViewsSorted() { - return registry; - }, + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; }, resetRegistry: () => { registry = []; }, }), - formatMsg: (x: any) => String(x), - formatStack: (x: any) => String(x), }; }); beforeEach(() => { - (getServices() as any).resetRegistry(); + (getDocViewsRegistry() as any).resetRegistry(); jest.clearAllMocks(); }); test('Render with 3 different tabs', () => { - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'Render function', render: jest.fn() }); registry.addDocView({ order: 20, title: 'React component', component: () =>
test
}); registry.addDocView({ order: 30, title: 'Invalid doc view' }); @@ -69,7 +65,7 @@ test('Render with 1 tab displaying error message', () => { return null; } - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'React component', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx index a177d8c29304c..792d9c44400d7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiTabbedContent } from '@elastic/eui'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewerTab } from './doc_viewer_tab'; import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; @@ -29,7 +29,7 @@ import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; * a `render` function. */ export function DocViewer(renderProps: DocViewRenderProps) { - const { docViewsRegistry } = getServices(); + const docViewsRegistry = getDocViewsRegistry(); const tabs = docViewsRegistry .getDocViewsSorted(renderProps.hit) .map(({ title, render, component }: DocView, idx: number) => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx index 075217add7b52..387e57dc8a7e3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; -import { formatMsg, formatStack } from '../../../kibana_services'; +import { formatMsg, formatStack } from '../../../../kibana_legacy/public'; interface Props { error: Error | string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap b/src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap rename to src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx b/src/plugins/discover/public/components/field_name/field_name.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx rename to src/plugins/discover/public/components/field_name/field_name.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx b/src/plugins/discover/public/components/field_name/field_name.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx rename to src/plugins/discover/public/components/field_name/field_name.tsx index 1b3b16332fa4f..63518aae28de6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx +++ b/src/plugins/discover/public/components/field_name/field_name.tsx @@ -20,8 +20,8 @@ import React from 'react'; import classNames from 'classnames'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { FieldIcon, FieldIconProps } from '../../../../../../../../../plugins/kibana_react/public'; -import { shortenDottedString } from '../../../helpers'; +import { FieldIcon, FieldIconProps } from '../../../../kibana_react/public'; +import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './field_type_name'; // property field is provided at discover's field chooser diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts b/src/plugins/discover/public/components/field_name/field_type_name.ts similarity index 66% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts rename to src/plugins/discover/public/components/field_name/field_type_name.ts index 0cf428ee48b9d..a67c20fc4f353 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts +++ b/src/plugins/discover/public/components/field_name/field_type_name.ts @@ -21,52 +21,52 @@ import { i18n } from '@kbn/i18n'; export function getFieldTypeName(type: string) { switch (type) { case 'boolean': - return i18n.translate('kbn.discover.fieldNameIcons.booleanAriaLabel', { + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { defaultMessage: 'Boolean field', }); case 'conflict': - return i18n.translate('kbn.discover.fieldNameIcons.conflictFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { defaultMessage: 'Conflicting field', }); case 'date': - return i18n.translate('kbn.discover.fieldNameIcons.dateFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { defaultMessage: 'Date field', }); case 'geo_point': - return i18n.translate('kbn.discover.fieldNameIcons.geoPointFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { defaultMessage: 'Geo point field', }); case 'geo_shape': - return i18n.translate('kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { defaultMessage: 'Geo shape field', }); case 'ip': - return i18n.translate('kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { defaultMessage: 'IP address field', }); case 'murmur3': - return i18n.translate('kbn.discover.fieldNameIcons.murmur3FieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { defaultMessage: 'Murmur3 field', }); case 'number': - return i18n.translate('kbn.discover.fieldNameIcons.numberFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { defaultMessage: 'Number field', }); case 'source': // Note that this type is currently not provided, type for _source is undefined - return i18n.translate('kbn.discover.fieldNameIcons.sourceFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { defaultMessage: 'Source field', }); case 'string': - return i18n.translate('kbn.discover.fieldNameIcons.stringFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { defaultMessage: 'String field', }); case 'nested': - return i18n.translate('kbn.discover.fieldNameIcons.nestedFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { defaultMessage: 'Nested field', }); default: - return i18n.translate('kbn.discover.fieldNameIcons.unknownFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { defaultMessage: 'Unknown field', }); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap rename to src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx index 9cab7974c9eb2..7e7f80c6aaa56 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { JsonCodeBlock } from './json_code_block'; -import { IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../../data/public'; it('returns the `JsonCodeEditor` component', () => { const props = { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.tsx index 3331969e351ab..9297ab0dfcf4d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; export function JsonCodeBlock({ hit }: DocViewRenderProps) { - const label = i18n.translate('kbn.discover.docViews.json.codeEditorAriaLabel', { + const label = i18n.translate('discover.docViews.json.codeEditorAriaLabel', { defaultMessage: 'Read only JSON view of an elasticsearch document', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx b/src/plugins/discover/public/components/table/table.test.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx rename to src/plugins/discover/public/components/table/table.test.tsx index 386f405544a61..91e116c4c6696 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx +++ b/src/plugins/discover/public/components/table/table.test.tsx @@ -21,10 +21,7 @@ import { mount } from 'enzyme'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DocViewTable } from './table'; - -import { IndexPattern, indexPatterns } from '../../../kibana_services'; - -jest.mock('ui/new_platform'); +import { indexPatterns, IndexPattern } from '../../../../data/public'; const indexPattern = { fields: [ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx b/src/plugins/discover/public/components/table/table.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx rename to src/plugins/discover/public/components/table/table.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts b/src/plugins/discover/public/components/table/table_helper.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts rename to src/plugins/discover/public/components/table/table_helper.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx b/src/plugins/discover/public/components/table/table_helper.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx rename to src/plugins/discover/public/components/table/table_helper.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx b/src/plugins/discover/public/components/table/table_row.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx rename to src/plugins/discover/public/components/table/table_row.tsx index 5b13f6b3655c3..a4d5c57d10b33 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx +++ b/src/plugins/discover/public/components/table/table_row.tsx @@ -26,7 +26,7 @@ import { DocViewTableRowBtnCollapse } from './table_row_btn_collapse'; import { DocViewTableRowBtnFilterExists } from './table_row_btn_filter_exists'; import { DocViewTableRowIconNoMapping } from './table_row_icon_no_mapping'; import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; -import { FieldName } from '../../angular/directives/field_name/field_name'; +import { FieldName } from '../field_name/field_name'; export interface Props { field: string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx rename to src/plugins/discover/public/components/table/table_row_btn_collapse.tsx index e59f607329d4a..bb5ea4bd20f07 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx @@ -26,7 +26,7 @@ export interface Props { } export function DocViewTableRowBtnCollapse({ onClick, isCollapsed }: Props) { - const label = i18n.translate('kbn.discover.docViews.table.toggleFieldDetails', { + const label = i18n.translate('discover.docViews.table.toggleFieldDetails', { defaultMessage: 'Toggle field details', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx rename to src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx index 8e2668e26cf08..bd842eb5c6f72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx @@ -29,12 +29,12 @@ export interface Props { export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props) { const tooltipContent = disabled ? ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props return ( ) : ( ) ) : ( ); @@ -54,12 +54,9 @@ export function DocViewTableRowBtnFilterExists({ return ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterRemove({ onClick, disabled = false }: Pr return ( } > Index Patterns page', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx rename to src/plugins/discover/public/components/table/table_row_icon_underscore.tsx index 724b5712cf1fe..791ab18de5175 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx +++ b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx @@ -22,13 +22,13 @@ import { i18n } from '@kbn/i18n'; export function DocViewTableRowIconUnderscore() { const ariaLabel = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', { defaultMessage: 'Warning', } ); const tooltipContent = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', { defaultMessage: 'Field names beginning with {underscoreSign} are not supported', values: { underscoreSign: '_' }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx b/src/plugins/discover/public/doc_views/doc_views_helpers.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx rename to src/plugins/discover/public/doc_views/doc_views_helpers.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts b/src/plugins/discover/public/doc_views/doc_views_registry.ts similarity index 82% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts rename to src/plugins/discover/public/doc_views/doc_views_registry.ts index 91acf1c7ac4ae..8f4518538be72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/doc_views/doc_views_registry.ts @@ -23,8 +23,11 @@ import { DocView, DocViewInput, ElasticSearchHit, DocViewInputFn } from './doc_v export class DocViewsRegistry { private docViews: DocView[] = []; + private angularInjectorGetter: (() => Promise) | null = null; - constructor(private getInjector: () => Promise) {} + setAngularInjectorGetter(injectorGetter: () => Promise) { + this.angularInjectorGetter = injectorGetter; + } /** * Extends and adds the given doc view to the registry array @@ -33,7 +36,12 @@ export class DocViewsRegistry { const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; if (docView.directive) { // convert angular directive to render function for backwards compatibility - docView.render = convertDirectiveToRenderFn(docView.directive, this.getInjector); + docView.render = convertDirectiveToRenderFn(docView.directive, () => { + if (!this.angularInjectorGetter) { + throw new Error('Angular was not initialized'); + } + return this.angularInjectorGetter(); + }); } if (typeof docView.shouldShow !== 'function') { docView.shouldShow = () => true; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts b/src/plugins/discover/public/doc_views/doc_views_types.ts similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts rename to src/plugins/discover/public/doc_views/doc_views_types.ts index a7828f9f0e7ed..0a4b5bb570bd7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/doc_views/doc_views_types.ts @@ -18,10 +18,10 @@ */ import { ComponentType } from 'react'; import { IScope } from 'angular'; -import { IndexPattern } from '../../kibana_services'; +import { IndexPattern } from '../../../data/public'; export interface AngularDirective { - controller: (scope: AngularScope) => void; + controller: (...injectedServices: any[]) => void; template: string; } @@ -51,13 +51,14 @@ export interface DocViewRenderProps { onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } +export type DocViewerComponent = ComponentType; export type DocViewRenderFn = ( domeNode: HTMLDivElement, renderProps: DocViewRenderProps ) => () => void; export interface DocViewInput { - component?: ComponentType; + component?: DocViewerComponent; directive?: AngularDirective; order: number; render?: DocViewRenderFn; diff --git a/src/legacy/server/i18n/constants.js b/src/plugins/discover/public/helpers/index.ts similarity index 92% rename from src/legacy/server/i18n/constants.js rename to src/plugins/discover/public/helpers/index.ts index a7a410dbcb5b3..7196c96989e97 100644 --- a/src/legacy/server/i18n/constants.js +++ b/src/plugins/discover/public/helpers/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export const I18N_RC = '.i18nrc.json'; +export { shortenDottedString } from './shorten_dotted_string'; diff --git a/src/plugins/discover/public/helpers/shorten_dotted_string.ts b/src/plugins/discover/public/helpers/shorten_dotted_string.ts new file mode 100644 index 0000000000000..9d78a96784339 --- /dev/null +++ b/src/plugins/discover/public/helpers/shorten_dotted_string.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const DOT_PREFIX_RE = /(.).+?\./g; + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover/public/index.scss b/src/plugins/discover/public/index.scss new file mode 100644 index 0000000000000..841415620d691 --- /dev/null +++ b/src/plugins/discover/public/index.scss @@ -0,0 +1 @@ +@import 'components/index'; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index c5050147c3d5a..dbc361ee59f49 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,5 +17,18 @@ * under the License. */ +import { DiscoverPlugin } from './plugin'; + +export { DiscoverSetup, DiscoverStart } from './plugin'; +export { DocViewTable } from './components/table/table'; +export { JsonCodeBlock } from './components/json_code_block/json_code_block'; +export { DocViewInput, DocViewInputFn, DocViewerComponent } from './doc_views/doc_views_types'; +export { FieldName } from './components/field_name/field_name'; +export * from './doc_views/doc_views_types'; + +export function plugin() { + return new DiscoverPlugin(); +} + export { createSavedSearchesLoader } from './saved_searches/saved_searches'; export { SavedSearchLoader, SavedSearch } from './saved_searches/types'; diff --git a/src/legacy/core_plugins/kibana_react/index.ts b/src/plugins/discover/public/mocks.ts similarity index 58% rename from src/legacy/core_plugins/kibana_react/index.ts rename to src/plugins/discover/public/mocks.ts index f4083f3d50c34..bb05e3d412001 100644 --- a/src/legacy/core_plugins/kibana_react/index.ts +++ b/src/plugins/discover/public/mocks.ts @@ -17,25 +17,31 @@ * under the License. */ -import { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; +import { DiscoverSetup, DiscoverStart } from '.'; -// eslint-disable-next-line import/no-default-export -export default function DataPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'kibana_react', - require: [], - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + docViews: { + addDocView: jest.fn(), + setAngularInjectorGetter: jest.fn(), }, - init: (server: Legacy.Server) => ({}), - uiExports: { - injectDefaultVars: () => ({}), - styleSheetPaths: resolve(__dirname, 'public/index.scss'), + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + docViews: { + DocViewer: jest.fn(() => null), }, }; + return startContract; +}; - return new kibana.Plugin(config); -} +export const discoverPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts new file mode 100644 index 0000000000000..d2797586bfdfb --- /dev/null +++ b/src/plugins/discover/public/plugin.ts @@ -0,0 +1,110 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { auto } from 'angular'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { DocViewInput, DocViewInputFn, DocViewRenderProps } from './doc_views/doc_views_types'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; +import { DocViewTable } from './components/table/table'; +import { JsonCodeBlock } from './components/json_code_block/json_code_block'; +import { DocViewer } from './components/doc_viewer/doc_viewer'; +import { setDocViewsRegistry } from './services'; + +import './index.scss'; + +/** + * @public + */ +export interface DiscoverSetup { + docViews: { + /** + * Add new doc view shown along with table view and json view in the details of each document in Discover. + * Both react and angular doc views are supported. + * @param docViewRaw + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; + /** + * Set the angular injector for bootstrapping angular doc views. This is only exposed temporarily to aid + * migration to the new platform and will be removed soon. + * @deprecated + * @param injectorGetter + */ + setAngularInjectorGetter(injectorGetter: () => Promise): void; + }; +} +/** + * @public + */ +export interface DiscoverStart { + docViews: { + /** + * Component rendering all the doc views for a given document. + * This is only exposed temporarily to aid migration to the new platform and will be removed soon. + * @deprecated + */ + DocViewer: React.ComponentType; + }; +} + +/** + * Contains Discover, one of the oldest parts of Kibana + * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular + * Discover provides embeddables, those contain a slimmer Angular + */ +export class DiscoverPlugin implements Plugin { + private docViewsRegistry: DocViewsRegistry | null = null; + + setup(core: CoreSetup): DiscoverSetup { + this.docViewsRegistry = new DocViewsRegistry(); + setDocViewsRegistry(this.docViewsRegistry); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.table.tableTitle', { + defaultMessage: 'Table', + }), + order: 10, + component: DocViewTable, + }); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.json.jsonTitle', { + defaultMessage: 'JSON', + }), + order: 20, + component: JsonCodeBlock, + }); + + return { + docViews: { + addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), + setAngularInjectorGetter: this.docViewsRegistry.setAngularInjectorGetter.bind( + this.docViewsRegistry + ), + }, + }; + } + + start() { + return { + docViews: { + DocViewer, + }, + }; + } +} diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 72983b7835eee..56360b04a49c8 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -16,7 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { createSavedObjectClass, SavedObjectKibanaServices } from '../../../saved_objects/public'; +import { + createSavedObjectClass, + SavedObject, + SavedObjectKibanaServices, +} from '../../../saved_objects/public'; export function createSavedSearchClass(services: SavedObjectKibanaServices) { const SavedObjectClass = createSavedObjectClass(services); @@ -66,5 +70,5 @@ export function createSavedSearchClass(services: SavedObjectKibanaServices) { } } - return SavedSearch; + return SavedSearch as new (id: string) => SavedObject; } diff --git a/src/plugins/discover/public/services.ts b/src/plugins/discover/public/services.ts new file mode 100644 index 0000000000000..3a28759d82b71 --- /dev/null +++ b/src/plugins/discover/public/services.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createGetterSetter } from '../../kibana_utils/common'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; + +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index d7da47a9317a0..7317b0c87c0b6 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -28,7 +28,7 @@ export interface EmbeddableVisTriggerContext ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSections: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts index 2f323c4b6a9cf..483605341ae4c 100644 --- a/src/plugins/management/public/management_section.ts +++ b/src/plugins/management/public/management_section.ts @@ -19,7 +19,7 @@ import { CreateSection, RegisterManagementAppArgs } from './types'; import { KibanaLegacySetup } from '../../kibana_legacy/public'; -import { CoreSetup } from '../../../core/public'; +import { StartServicesAccessor } from '../../../core/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { ManagementApp } from './management_app'; @@ -34,14 +34,14 @@ export class ManagementSection { private readonly getSections: () => ManagementSection[]; private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; private readonly getLegacyManagementSection: () => LegacyManagementSection; - private readonly getStartServices: CoreSetup['getStartServices']; + private readonly getStartServices: StartServicesAccessor; constructor( { id, title, order = 100, euiIconType, icon }: CreateSection, getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSection: () => ManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts index 4a900345b3843..ed31a22992da8 100644 --- a/src/plugins/management/public/management_service.ts +++ b/src/plugins/management/public/management_service.ts @@ -22,7 +22,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { CreateSection } from './types'; -import { CoreSetup, CoreStart } from '../../../core/public'; +import { StartServicesAccessor, CoreStart } from '../../../core/public'; export class ManagementService { private sections: ManagementSection[] = []; @@ -30,7 +30,7 @@ export class ManagementService { private register( registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { return (section: CreateSection) => { if (this.getSection(section.id)) { @@ -71,7 +71,7 @@ export class ManagementService { public setup( kibanaLegacy: KibanaLegacySetup, getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { const register = this.register.bind(this)( kibanaLegacy.registerLegacyApp, diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap new file mode 100644 index 0000000000000..0d54d5d3e9c4a --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavMenu Should render emphasized item which should be clickable 1`] = ` + + Test + +`; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 4a0e6af3f7f70..5befe4789dd6c 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,7 +1,11 @@ .kbnTopNavMenu__wrapper { z-index: 5; - .kbnTopNavMenu { - padding: $euiSizeS 0px $euiSizeXS; + .kbnTopNavMenu { + padding: $euiSizeS 0; + + .kbnTopNavItemEmphasized { + padding: 0 $euiSizeS; + } } } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 80d1a53cd417f..14ad40f13e388 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -46,7 +46,11 @@ export function TopNavMenu(props: TopNavMenuProps) { if (!config) return; return config.map((menuItem: TopNavMenuData, i: number) => { return ( - + ); @@ -66,6 +70,7 @@ export function TopNavMenu(props: TopNavMenuProps) { void; export interface TopNavMenuData { @@ -28,6 +30,9 @@ export interface TopNavMenuData { className?: string; disableButton?: boolean | (() => boolean); tooltip?: string | (() => string); + emphasize?: boolean; + iconType?: string; + iconSide?: ButtonIconSide; } export interface RegisteredTopNavMenuData extends TopNavMenuData { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx index 4816ef3c95869..9ba58379c5ce1 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx @@ -23,6 +23,15 @@ import { TopNavMenuData } from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; describe('TopNavMenu', () => { + const ensureMenuItemDisabled = (data: TopNavMenuData) => { + const component = shallowWithIntl(); + expect(component.prop('isDisabled')).toEqual(true); + + const event = { currentTarget: { value: 'a' } }; + component.simulate('click', event); + expect(data.run).toHaveBeenCalledTimes(0); + }; + it('Should render and click an item', () => { const data: TopNavMenuData = { id: 'test', @@ -60,35 +69,62 @@ describe('TopNavMenu', () => { expect(data.run).toHaveBeenCalled(); }); - it('Should render disabled item and it shouldnt be clickable', () => { + it('Should render emphasized item which should be clickable', () => { const data: TopNavMenuData = { id: 'test', label: 'test', - disableButton: true, + iconType: 'beaker', + iconSide: 'right', + emphasize: true, run: jest.fn(), }; const component = shallowWithIntl(); - expect(component.prop('isDisabled')).toEqual(true); - const event = { currentTarget: { value: 'a' } }; component.simulate('click', event); - expect(data.run).toHaveBeenCalledTimes(0); + expect(data.run).toHaveBeenCalledTimes(1); + expect(component).toMatchSnapshot(); + }); + + it('Should render disabled item and it shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + disableButton: true, + run: jest.fn(), + }); }); it('Should render item with disable function and it shouldnt be clickable', () => { - const data: TopNavMenuData = { + ensureMenuItemDisabled({ id: 'test', label: 'test', disableButton: () => true, run: jest.fn(), - }; + }); + }); - const component = shallowWithIntl(); - expect(component.prop('isDisabled')).toEqual(true); + it('Should render disabled emphasized item which shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + iconType: 'beaker', + iconSide: 'right', + emphasize: true, + disableButton: true, + run: jest.fn(), + }); + }); - const event = { currentTarget: { value: 'a' } }; - component.simulate('click', event); - expect(data.run).toHaveBeenCalledTimes(0); + it('Should render emphasized item with disable function and it shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + iconType: 'beaker', + iconSide: 'right', + emphasize: true, + disableButton: () => true, + run: jest.fn(), + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 4d3b72bae6411..92e267f17d08e 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -21,6 +21,7 @@ import { capitalize, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -39,14 +40,20 @@ export function TopNavMenuItem(props: TopNavMenuData) { props.run(e.currentTarget); } - const btn = ( - + const commonButtonProps = { + isDisabled: isDisabled(), + onClick: handleClick, + iconType: props.iconType, + iconSide: props.iconSide, + 'data-test-subj': props.testId, + }; + + const btn = props.emphasize ? ( + + {capitalize(props.label || props.id!)} + + ) : ( + {capitalize(props.label || props.id!)} ); @@ -54,9 +61,8 @@ export function TopNavMenuItem(props: TopNavMenuData) { const tooltip = getTooltip(); if (tooltip) { return {btn}; - } else { - return btn; } + return btn; } TopNavMenuItem.defaultProps = { diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index ea92c921efad0..9e0a7c40c043f 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -21,7 +21,13 @@ import { SavedObjectsPublicPlugin } from './plugin'; export { OnSaveProps, SavedObjectSaveModal, SaveResult, showSaveModal } from './save_modal'; export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; -export { SavedObjectLoader, createSavedObjectClass } from './saved_object'; +export { + SavedObjectLoader, + createSavedObjectClass, + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, +} from './saved_object'; export { SavedObjectSaveOpts, SavedObjectKibanaServices, SavedObject } from './types'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 1d145bc97bdb4..95eb56c0e874b 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -289,7 +289,7 @@ export class SavedObjectSaveModal extends React.Component {

{ + const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; + const overlays: OverlayStart = {} as OverlayStart; + const source: SavedObjectAttributes = {} as SavedObjectAttributes; + const options: SavedObjectsCreateOptions = {} as SavedObjectsCreateOptions; + const savedObject = { + getEsType: () => 'test type', + title: 'test title', + displayName: 'test display name', + }; + + beforeEach(() => { + savedObjectsClient.create = jest.fn(); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.resolve({} as any)); + }); + + test('should call create of savedObjectsClient', async () => { + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + savedObject.getEsType(), + source, + options + ); + }); + + test('should call confirmModalPromise when such record exists', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(deps.confirmModalPromise).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + overlays + ); + }); + + test('should call create of savedObjectsClient when overwriting confirmed', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenLastCalledWith(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }); + }); + + test('should reject when overwriting denied', async () => { + savedObjectsClient.create = jest.fn().mockReturnValue(Promise.reject({ res: { status: 409 } })); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.reject()); + + expect.assertions(1); + await expect( + saveWithConfirmation(source, savedObject, options, { + savedObjectsClient, + overlays, + }) + ).rejects.toThrow(OVERWRITE_REJECTED); + }); +}); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts new file mode 100644 index 0000000000000..b413ea19a932d --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectAttributes, + SavedObjectsCreateOptions, + OverlayStart, + SavedObjectsClientContract, +} from 'kibana/public'; +import { OVERWRITE_REJECTED } from '../../constants'; +import { confirmModalPromise } from './confirm_modal_promise'; + +/** + * Attempts to create the current object using the serialized source. If an object already + * exists, a warning message requests an overwrite confirmation. + * @param source - serialized version of this object what will be indexed into elasticsearch. + * @param savedObject - a simple object that contains properties title and displayName, and getEsType method + * @param options - options to pass to the saved object create method + * @param services - provides Kibana services savedObjectsClient and overlays + * @returns {Promise} - A promise that is resolved with the objects id if the object is + * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with + * a confirmRejected = true parameter so that case can be handled differently than + * a create or index error. + * @resolved {SavedObject} + */ +export async function saveWithConfirmation( + source: SavedObjectAttributes, + savedObject: { + getEsType(): string; + title: string; + displayName: string; + }, + options: SavedObjectsCreateOptions, + services: { savedObjectsClient: SavedObjectsClientContract; overlays: OverlayStart } +) { + const { savedObjectsClient, overlays } = services; + try { + return await savedObjectsClient.create(savedObject.getEsType(), source, options); + } catch (err) { + // record exists, confirm overwriting + if (get(err, 'res.status') === 409) { + const confirmMessage = i18n.translate( + 'savedObjects.confirmModal.overwriteConfirmationMessage', + { + defaultMessage: 'Are you sure you want to overwrite {title}?', + values: { title: savedObject.title }, + } + ); + + const title = i18n.translate('savedObjects.confirmModal.overwriteTitle', { + defaultMessage: 'Overwrite {name}?', + values: { name: savedObject.displayName }, + }); + const confirmButtonText = i18n.translate('savedObjects.confirmModal.overwriteButtonLabel', { + defaultMessage: 'Overwrite', + }); + + return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays) + .then(() => + savedObjectsClient.create(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }) + ) + .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); + } + return await Promise.reject(err); + } +} diff --git a/src/plugins/saved_objects/public/saved_object/index.ts b/src/plugins/saved_objects/public/saved_object/index.ts index d3be5ea6df617..178ffaf88f4be 100644 --- a/src/plugins/saved_objects/public/saved_object/index.ts +++ b/src/plugins/saved_objects/public/saved_object/index.ts @@ -19,3 +19,6 @@ export { createSavedObjectClass } from './saved_object'; export { SavedObjectLoader } from './saved_object_loader'; +export { checkForDuplicateTitle } from './helpers/check_for_duplicate_title'; +export { saveWithConfirmation } from './helpers/save_with_confirmation'; +export { isErrorNonFatal } from './helpers/save_saved_object'; diff --git a/src/legacy/ui/public/share/_index.scss b/src/plugins/share/public/components/_index.scss similarity index 100% rename from src/legacy/ui/public/share/_index.scss rename to src/plugins/share/public/components/_index.scss diff --git a/src/legacy/ui/public/share/_share_context_menu.scss b/src/plugins/share/public/components/_share_context_menu.scss similarity index 100% rename from src/legacy/ui/public/share/_share_context_menu.scss rename to src/plugins/share/public/components/_share_context_menu.scss diff --git a/src/plugins/share/public/index.scss b/src/plugins/share/public/index.scss new file mode 100644 index 0000000000000..0271fbb8e9026 --- /dev/null +++ b/src/plugins/share/public/index.scss @@ -0,0 +1 @@ +@import './components/index' \ No newline at end of file diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 5b638174b4dfb..d02f51af42905 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -17,6 +17,8 @@ * under the License. */ +import './index.scss'; + import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; diff --git a/src/legacy/core_plugins/telemetry/README.md b/src/plugins/telemetry/README.md similarity index 81% rename from src/legacy/core_plugins/telemetry/README.md rename to src/plugins/telemetry/README.md index 830c08f8e8bed..196d596fb784f 100644 --- a/src/legacy/core_plugins/telemetry/README.md +++ b/src/plugins/telemetry/README.md @@ -6,4 +6,4 @@ Telemetry allows Kibana features to have usage tracked in the wild. The general 2. Sending a payload of usage data up to Elastic's telemetry cluster. 3. Viewing usage data in the Kibana instance of the telemetry cluster (Viewing). -This plugin is responsible for sending usage data to the telemetry cluster. For collecting usage data, use +This plugin is responsible for sending usage data to the telemetry cluster. For collecting usage data, use the [`usageCollection` plugin](../usage_collection/README.md) diff --git a/src/plugins/telemetry/common/constants.ts b/src/plugins/telemetry/common/constants.ts index 7b7694ed9aed7..babd009143c5e 100644 --- a/src/plugins/telemetry/common/constants.ts +++ b/src/plugins/telemetry/common/constants.ts @@ -17,13 +17,31 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + +/** + * config options opt into telemetry + */ +export const CONFIG_TELEMETRY = 'telemetry:optIn'; + +/** + * config description for opting into telemetry + */ +export const getConfigTelemetryDesc = () => { + // Can't find where it's used but copying it over from the legacy code just in case... + return i18n.translate('telemetry.telemetryConfigDescription', { + defaultMessage: + 'Help us improve the Elastic Stack by providing usage statistics for basic features. We will not share this data outside of Elastic.', + }); +}; + /** * The amount of time, in milliseconds, to wait between reports when enabled. * Currently 24 hours. */ export const REPORT_INTERVAL_MS = 86400000; -/* +/** * Key for the localStorage service */ export const LOCALSTORAGE_KEY = 'telemetry.data'; @@ -37,3 +55,28 @@ export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; * Link to the Elastic Telemetry privacy statement. */ export const PRIVACY_STATEMENT_URL = `https://www.elastic.co/legal/privacy-statement`; + +/** + * The type name used to publish telemetry plugin stats. + */ +export const TELEMETRY_STATS_TYPE = 'telemetry'; + +/** + * The endpoint version when hitting the remote telemetry service + */ +export const ENDPOINT_VERSION = 'v2'; + +/** + * UI metric usage type + */ +export const UI_METRIC_USAGE_TYPE = 'ui_metric'; + +/** + * Application Usage type + */ +export const APPLICATION_USAGE_TYPE = 'application_usage'; + +/** + * The type name used within the Monitoring index to publish management stats. + */ +export const KIBANA_STACK_MANAGEMENT_STATS_TYPE = 'stack_management'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts similarity index 93% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts index 9fa4fbc5e0227..d7dcfd606b6ac 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_allow_changing_opt_in_status.ts @@ -16,7 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; + +import { TelemetrySavedObject } from './types'; interface GetTelemetryAllowChangingOptInStatus { configTelemetryAllowChangingOptInStatus: boolean; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.ts index 2952fa96a5cf3..c23ec42be56c4 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_failure_details.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_failure_details.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface GetTelemetryFailureDetailsConfig { telemetrySavedObject: TelemetrySavedObject; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.ts index 8ef3bd8388ecb..19bd1974ffba1 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_notify_user_about_optin_default.ts @@ -17,7 +17,7 @@ * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface NotifyOpts { allowChangingOptInStatus: boolean; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts similarity index 98% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts index efc4a020e0ff0..da44abd35517c 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.test.ts @@ -18,7 +18,7 @@ */ import { getTelemetryOptIn } from './get_telemetry_opt_in'; -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; describe('getTelemetryOptIn', () => { it('returns null when saved object not found', () => { diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.ts similarity index 97% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.ts index d83ffdf69b576..7beb5415ad7b1 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_opt_in.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_opt_in.ts @@ -18,7 +18,7 @@ */ import semver from 'semver'; -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface GetTelemetryOptInConfig { telemetrySavedObject: TelemetrySavedObject; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.test.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.test.ts similarity index 96% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.test.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.test.ts index 69868a97a931d..2cf68f0abedea 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.test.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.test.ts @@ -18,7 +18,7 @@ */ import { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; describe('getTelemetrySendUsageFrom', () => { it('returns kibana.yml config when saved object not found', () => { diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.ts b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.ts similarity index 93% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.ts rename to src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.ts index 9e4ae14b6097c..78dc1d877c47f 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_send_usage_from.ts +++ b/src/plugins/telemetry/common/telemetry_config/get_telemetry_send_usage_from.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; +import { TelemetrySavedObject } from './types'; interface GetTelemetryUsageFetcherConfig { configTelemetrySendUsageFrom: 'browser' | 'server'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/index.ts b/src/plugins/telemetry/common/telemetry_config/index.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/telemetry_config/index.ts rename to src/plugins/telemetry/common/telemetry_config/index.ts index bf9855ce7538e..51eb4dd16105e 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/index.ts +++ b/src/plugins/telemetry/common/telemetry_config/index.ts @@ -17,7 +17,6 @@ * under the License. */ -export { replaceTelemetryInjectedVars } from './replace_injected_vars'; export { getTelemetryOptIn } from './get_telemetry_opt_in'; export { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; export { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts b/src/plugins/telemetry/common/telemetry_config/types.ts similarity index 86% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts rename to src/plugins/telemetry/common/telemetry_config/types.ts index f1735d1bb2866..7ab37e9544164 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts +++ b/src/plugins/telemetry/common/telemetry_config/types.ts @@ -17,9 +17,6 @@ * under the License. */ -export { getTelemetrySavedObject, TelemetrySavedObject } from './get_telemetry_saved_object'; -export { updateTelemetrySavedObject } from './update_telemetry_saved_object'; - export interface TelemetrySavedObjectAttributes { enabled?: boolean | null; lastVersionChecked?: string; @@ -30,3 +27,5 @@ export interface TelemetrySavedObjectAttributes { reportFailureCount?: number; reportFailureVersion?: string; } + +export type TelemetrySavedObject = TelemetrySavedObjectAttributes | null | false; diff --git a/src/plugins/telemetry/kibana.json b/src/plugins/telemetry/kibana.json index 3a28149276c3e..f623f4f2a565d 100644 --- a/src/plugins/telemetry/kibana.json +++ b/src/plugins/telemetry/kibana.json @@ -1,6 +1,10 @@ { "id": "telemetry", "version": "kibana", - "server": false, - "ui": true + "server": true, + "ui": true, + "requiredPlugins": [ + "telemetryCollectionManager", + "usageCollection" + ] } diff --git a/src/plugins/telemetry/public/components/index.ts b/src/plugins/telemetry/public/components/index.ts index f4341154f527a..8fda181b2ed93 100644 --- a/src/plugins/telemetry/public/components/index.ts +++ b/src/plugins/telemetry/public/components/index.ts @@ -17,6 +17,4 @@ * under the License. */ -export { OptInExampleFlyout } from './opt_in_example_flyout'; -export { TelemetryManagementSection } from './telemetry_management_section'; export { OptedInNoticeBanner } from './opted_in_notice_banner'; diff --git a/src/plugins/telemetry/public/index.ts b/src/plugins/telemetry/public/index.ts index 2f86d7749bb9b..665c89ba2bffa 100644 --- a/src/plugins/telemetry/public/index.ts +++ b/src/plugins/telemetry/public/index.ts @@ -17,9 +17,10 @@ * under the License. */ -import { TelemetryPlugin } from './plugin'; +import { PluginInitializerContext } from 'kibana/public'; +import { TelemetryPlugin, TelemetryPluginConfig } from './plugin'; export { TelemetryPluginStart, TelemetryPluginSetup } from './plugin'; -export function plugin() { - return new TelemetryPlugin(); +export function plugin(initializerContext: PluginInitializerContext) { + return new TelemetryPlugin(initializerContext); } diff --git a/src/plugins/telemetry/public/mocks.ts b/src/plugins/telemetry/public/mocks.ts index 93dc13c327509..4e0f02242961a 100644 --- a/src/plugins/telemetry/public/mocks.ts +++ b/src/plugins/telemetry/public/mocks.ts @@ -23,8 +23,6 @@ import { overlayServiceMock } from '../../../core/public/overlays/overlay_servic import { httpServiceMock } from '../../../core/public/http/http_service.mock'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { notificationServiceMock } from '../../../core/public/notifications/notifications_service.mock'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { injectedMetadataServiceMock } from '../../../core/public/injected_metadata/injected_metadata_service.mock'; import { TelemetryService } from './services/telemetry_service'; import { TelemetryNotifications } from './services/telemetry_notifications/telemetry_notifications'; import { TelemetryPluginStart } from './plugin'; @@ -32,23 +30,19 @@ import { TelemetryPluginStart } from './plugin'; export function mockTelemetryService({ reportOptInStatusChange, }: { reportOptInStatusChange?: boolean } = {}) { - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getInjectedVar.mockImplementation((key: string) => { - switch (key) { - case 'telemetryNotifyUserAboutOptInDefault': - return true; - case 'allowChangingOptInStatus': - return true; - case 'telemetryOptedIn': - return true; - default: { - throw Error(`Unhandled getInjectedVar key "${key}".`); - } - } - }); + const config = { + enabled: true, + url: 'http://localhost', + optInStatusUrl: 'http://localhost', + sendUsageFrom: 'browser' as const, + optIn: true, + banner: true, + allowChangingOptInStatus: true, + telemetryNotifyUserAboutOptInDefault: true, + }; return new TelemetryService({ - injectedMetadata, + config, http: httpServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), reportOptInStatusChange, diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 9cfb4ca1ec395..86679227059e6 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -16,9 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import { Plugin, CoreStart, CoreSetup, HttpStart } from '../../../core/public'; + +import { + Plugin, + CoreStart, + CoreSetup, + HttpStart, + PluginInitializerContext, + SavedObjectsClientContract, +} from '../../../core/public'; import { TelemetrySender, TelemetryService, TelemetryNotifications } from './services'; +import { + TelemetrySavedObjectAttributes, + TelemetrySavedObject, +} from '../common/telemetry_config/types'; +import { + getTelemetryAllowChangingOptInStatus, + getTelemetryOptIn, + getTelemetrySendUsageFrom, +} from '../common/telemetry_config'; +import { getNotifyUserAboutOptInDefault } from '../common/telemetry_config/get_telemetry_notify_user_about_optin_default'; export interface TelemetryPluginSetup { telemetryService: TelemetryService; @@ -29,17 +47,32 @@ export interface TelemetryPluginStart { telemetryNotifications: TelemetryNotifications; } +export interface TelemetryPluginConfig { + enabled: boolean; + url: string; + banner: boolean; + allowChangingOptInStatus: boolean; + optIn: boolean | null; + optInStatusUrl: string; + sendUsageFrom: 'browser' | 'server'; + telemetryNotifyUserAboutOptInDefault?: boolean; +} + export class TelemetryPlugin implements Plugin { + private readonly currentKibanaVersion: string; + private readonly config: TelemetryPluginConfig; private telemetrySender?: TelemetrySender; private telemetryNotifications?: TelemetryNotifications; private telemetryService?: TelemetryService; - public setup({ http, injectedMetadata, notifications }: CoreSetup): TelemetryPluginSetup { - this.telemetryService = new TelemetryService({ - http, - injectedMetadata, - notifications, - }); + constructor(initializerContext: PluginInitializerContext) { + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + this.config = initializerContext.config.get(); + } + + public setup({ http, notifications }: CoreSetup): TelemetryPluginSetup { + const config = this.config; + this.telemetryService = new TelemetryService({ config, http, notifications }); this.telemetrySender = new TelemetrySender(this.telemetryService); @@ -48,24 +81,29 @@ export class TelemetryPlugin implements Plugin { + application.currentAppId$.subscribe(async () => { const isUnauthenticated = this.getIsUnauthenticated(http); if (isUnauthenticated) { return; } + // Update the telemetry config based as a mix of the config files and saved objects + const telemetrySavedObject = await this.getTelemetrySavedObject(savedObjects.client); + const updatedConfig = await this.updateConfigsBasedOnSavedObjects(telemetrySavedObject); + this.telemetryService!.config = updatedConfig; + + const telemetryBanner = updatedConfig.banner; + this.maybeStartTelemetryPoller(); if (telemetryBanner) { this.maybeShowOptedInNotificationBanner(); @@ -111,4 +149,66 @@ export class TelemetryPlugin implements Plugin { + const configTelemetrySendUsageFrom = this.config.sendUsageFrom; + const configTelemetryOptIn = this.config.optIn as boolean; + const configTelemetryAllowChangingOptInStatus = this.config.allowChangingOptInStatus; + + const currentKibanaVersion = this.currentKibanaVersion; + + const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ + configTelemetryAllowChangingOptInStatus, + telemetrySavedObject, + }); + + const optIn = getTelemetryOptIn({ + configTelemetryOptIn, + allowChangingOptInStatus, + telemetrySavedObject, + currentKibanaVersion, + }); + + const sendUsageFrom = getTelemetrySendUsageFrom({ + configTelemetrySendUsageFrom, + telemetrySavedObject, + }); + + const telemetryNotifyUserAboutOptInDefault = getNotifyUserAboutOptInDefault({ + telemetrySavedObject, + allowChangingOptInStatus, + configTelemetryOptIn, + telemetryOptedIn: optIn, + }); + + return { + ...this.config, + optIn, + sendUsageFrom, + telemetryNotifyUserAboutOptInDefault, + }; + } + + private async getTelemetrySavedObject(savedObjectsClient: SavedObjectsClientContract) { + try { + const { attributes } = await savedObjectsClient.get( + 'telemetry', + 'telemetry' + ); + return attributes; + } catch (error) { + const errorCode = error[Symbol('SavedObjectsClientErrorCode')]; + if (errorCode === 'SavedObjectsClient/notFound') { + return null; + } + + if (errorCode === 'SavedObjectsClient/forbidden') { + return false; + } + + throw error; + } + } } diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index cb91451bd8ef4..cac4e3fdf5f50 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -20,62 +20,75 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'kibana/public'; +import { TelemetryPluginConfig } from '../plugin'; interface TelemetryServiceConstructor { + config: TelemetryPluginConfig; http: CoreStart['http']; - injectedMetadata: CoreStart['injectedMetadata']; notifications: CoreStart['notifications']; reportOptInStatusChange?: boolean; } export class TelemetryService { private readonly http: CoreStart['http']; - private readonly injectedMetadata: CoreStart['injectedMetadata']; private readonly reportOptInStatusChange: boolean; private readonly notifications: CoreStart['notifications']; - private isOptedIn: boolean | null; - private userHasSeenOptedInNotice: boolean; + private readonly defaultConfig: TelemetryPluginConfig; + private updatedConfig?: TelemetryPluginConfig; constructor({ + config, http, - injectedMetadata, notifications, reportOptInStatusChange = true, }: TelemetryServiceConstructor) { - const isOptedIn = injectedMetadata.getInjectedVar('telemetryOptedIn') as boolean | null; - const userHasSeenOptedInNotice = injectedMetadata.getInjectedVar( - 'telemetryNotifyUserAboutOptInDefault' - ) as boolean; + this.defaultConfig = config; this.reportOptInStatusChange = reportOptInStatusChange; - this.injectedMetadata = injectedMetadata; this.notifications = notifications; this.http = http; + } + + public set config(updatedConfig: TelemetryPluginConfig) { + this.updatedConfig = updatedConfig; + } + + public get config() { + return { ...this.defaultConfig, ...this.updatedConfig }; + } + + public get isOptedIn() { + return this.config.optIn; + } + + public set isOptedIn(optIn) { + this.config = { ...this.config, optIn }; + } + + public get userHasSeenOptedInNotice() { + return this.config.telemetryNotifyUserAboutOptInDefault; + } - this.isOptedIn = isOptedIn; - this.userHasSeenOptedInNotice = userHasSeenOptedInNotice; + public set userHasSeenOptedInNotice(telemetryNotifyUserAboutOptInDefault) { + this.config = { ...this.config, telemetryNotifyUserAboutOptInDefault }; } public getCanChangeOptInStatus = () => { - const allowChangingOptInStatus = this.injectedMetadata.getInjectedVar( - 'allowChangingOptInStatus' - ) as boolean; + const allowChangingOptInStatus = this.config.allowChangingOptInStatus; return allowChangingOptInStatus; }; public getOptInStatusUrl = () => { - const telemetryOptInStatusUrl = this.injectedMetadata.getInjectedVar( - 'telemetryOptInStatusUrl' - ) as string; + const telemetryOptInStatusUrl = this.config.optInStatusUrl; return telemetryOptInStatusUrl; }; public getTelemetryUrl = () => { - const telemetryUrl = this.injectedMetadata.getInjectedVar('telemetryUrl') as string; + const telemetryUrl = this.config.url; return telemetryUrl; }; public getUserHasSeenOptedInNotice = () => { - return this.userHasSeenOptedInNotice; + return this.config.telemetryNotifyUserAboutOptInDefault || false; }; public getIsOptedIn = () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts b/src/plugins/telemetry/server/collectors/application_usage/index.test.ts similarity index 91% rename from src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts rename to src/plugins/telemetry/server/collectors/application_usage/index.test.ts index 1a64100bda692..5a8fa71363ba7 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.test.ts +++ b/src/plugins/telemetry/server/collectors/application_usage/index.test.ts @@ -17,10 +17,10 @@ * under the License. */ -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/server'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectorOptions } from '../../../../../../plugins/usage_collection/server/collector/collector'; +import { CollectorOptions } from '../../../../../plugins/usage_collection/server/collector/collector'; import { registerApplicationUsageCollector } from './'; import { @@ -40,9 +40,12 @@ describe('telemetry_application_usage', () => { } as any; const getUsageCollector = jest.fn(); + const registerType = jest.fn(); const callCluster = jest.fn(); - beforeAll(() => registerApplicationUsageCollector(usageCollectionMock, getUsageCollector)); + beforeAll(() => + registerApplicationUsageCollector(usageCollectionMock, registerType, getUsageCollector) + ); afterAll(() => jest.clearAllTimers()); test('registered collector is set', () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts b/src/plugins/telemetry/server/collectors/application_usage/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/application_usage/index.ts rename to src/plugins/telemetry/server/collectors/application_usage/index.ts diff --git a/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts new file mode 100644 index 0000000000000..9f997ab7b5df3 --- /dev/null +++ b/src/plugins/telemetry/server/collectors/application_usage/saved_objects_types.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; + +export interface ApplicationUsageTotal extends SavedObjectAttributes { + appId: string; + minutesOnScreen: number; + numberOfClicks: number; +} + +export interface ApplicationUsageTransactional extends ApplicationUsageTotal { + timestamp: string; +} + +export function registerMappings(registerType: SavedObjectsServiceSetup['registerType']) { + registerType({ + name: 'application_usage_totals', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + appId: { type: 'keyword' }, + numberOfClicks: { type: 'long' }, + minutesOnScreen: { type: 'float' }, + }, + }, + }); + + registerType({ + name: 'application_usage_transactional', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + timestamp: { type: 'date' }, + appId: { type: 'keyword' }, + numberOfClicks: { type: 'long' }, + minutesOnScreen: { type: 'float' }, + }, + }, + }); +} diff --git a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts similarity index 94% rename from src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts rename to src/plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts index 5c862686a37d9..f52687038bbbc 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/telemetry/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -18,10 +18,15 @@ */ import moment from 'moment'; +import { ISavedObjectsRepository, SavedObjectsServiceSetup } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APPLICATION_USAGE_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { ISavedObjectsRepository, SavedObjectAttributes } from '../../../../../../core/server'; import { findAll } from '../find_all'; +import { + ApplicationUsageTotal, + ApplicationUsageTransactional, + registerMappings, +} from './saved_objects_types'; /** * Roll indices every 24h @@ -36,16 +41,6 @@ export const ROLL_INDICES_START = 5 * 60 * 1000; export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; -interface ApplicationUsageTotal extends SavedObjectAttributes { - appId: string; - minutesOnScreen: number; - numberOfClicks: number; -} - -interface ApplicationUsageTransactional extends ApplicationUsageTotal { - timestamp: string; -} - interface ApplicationUsageTelemetryReport { [appId: string]: { clicks_total: number; @@ -61,8 +56,11 @@ interface ApplicationUsageTelemetryReport { export function registerApplicationUsageCollector( usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { + registerMappings(registerType); + const collector = usageCollection.makeUsageCollector({ type: APPLICATION_USAGE_TYPE, isReady: () => typeof getSavedObjectsClient() !== 'undefined', diff --git a/src/legacy/core_plugins/telemetry/server/collectors/find_all.test.ts b/src/plugins/telemetry/server/collectors/find_all.test.ts similarity index 96% rename from src/legacy/core_plugins/telemetry/server/collectors/find_all.test.ts rename to src/plugins/telemetry/server/collectors/find_all.test.ts index 012cda395bc6c..a62c74c0c0838 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/find_all.test.ts +++ b/src/plugins/telemetry/server/collectors/find_all.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; +import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; import { findAll } from './find_all'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/find_all.ts b/src/plugins/telemetry/server/collectors/find_all.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/find_all.ts rename to src/plugins/telemetry/server/collectors/find_all.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/index.ts b/src/plugins/telemetry/server/collectors/index.ts similarity index 90% rename from src/legacy/core_plugins/telemetry/server/collectors/index.ts rename to src/plugins/telemetry/server/collectors/index.ts index 6cb7a38b6414f..6eeda080bb3ab 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/index.ts +++ b/src/plugins/telemetry/server/collectors/index.ts @@ -17,10 +17,8 @@ * under the License. */ -export { encryptTelemetry } from './encryption'; export { registerTelemetryUsageCollector } from './usage'; export { registerUiMetricUsageCollector } from './ui_metric'; -export { registerLocalizationUsageCollector } from './localization'; export { registerTelemetryPluginUsageCollector } from './telemetry_plugin'; export { registerManagementUsageCollector } from './management'; export { registerApplicationUsageCollector } from './application_usage'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts b/src/plugins/telemetry/server/collectors/management/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/management/index.ts rename to src/plugins/telemetry/server/collectors/management/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts b/src/plugins/telemetry/server/collectors/management/telemetry_management_collector.ts similarity index 73% rename from src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts rename to src/plugins/telemetry/server/collectors/management/telemetry_management_collector.ts index 481b1e9af2a79..7dc4ca64e6bc3 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts +++ b/src/plugins/telemetry/server/collectors/management/telemetry_management_collector.ts @@ -17,11 +17,10 @@ * under the License. */ -import { Server } from 'hapi'; import { size } from 'lodash'; +import { IUiSettingsClient } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_STACK_MANAGEMENT_STATS_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { SavedObjectsClient } from '../../../../../../core/server'; export type UsageStats = Record; @@ -30,12 +29,12 @@ export async function getTranslationCount(loader: any, locale: string): Promise< return size(translations.messages); } -export function createCollectorFetch(server: Server) { - return async function fetchUsageStats(): Promise { - const internalRepo = server.newPlatform.start.core.savedObjects.createInternalRepository(); - const uiSettingsClient = server.newPlatform.start.core.uiSettings.asScopedToClient( - new SavedObjectsClient(internalRepo) - ); +export function createCollectorFetch(getUiSettingsClient: () => IUiSettingsClient | undefined) { + return async function fetchUsageStats(): Promise { + const uiSettingsClient = getUiSettingsClient(); + if (!uiSettingsClient) { + return; + } const user = await uiSettingsClient.getUserProvided(); const modifiedEntries = Object.keys(user) @@ -51,12 +50,12 @@ export function createCollectorFetch(server: Server) { export function registerManagementUsageCollector( usageCollection: UsageCollectionSetup, - server: any + getUiSettingsClient: () => IUiSettingsClient | undefined ) { const collector = usageCollection.makeUsageCollector({ type: KIBANA_STACK_MANAGEMENT_STATS_TYPE, - isReady: () => true, - fetch: createCollectorFetch(server), + isReady: () => typeof getUiSettingsClient() !== 'undefined', + fetch: createCollectorFetch(getUiSettingsClient), }); usageCollection.registerCollector(collector); diff --git a/src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/index.ts b/src/plugins/telemetry/server/collectors/telemetry_plugin/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/index.ts rename to src/plugins/telemetry/server/collectors/telemetry_plugin/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts b/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts similarity index 62% rename from src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts rename to src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts index 5e25538cbad80..ab90935266d69 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts +++ b/src/plugins/telemetry/server/collectors/telemetry_plugin/telemetry_plugin_collector.ts @@ -17,10 +17,14 @@ * under the License. */ +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ISavedObjectsRepository, SavedObjectsClient } from '../../../../../core/server'; import { TELEMETRY_STATS_TYPE } from '../../../common/constants'; import { getTelemetrySavedObject, TelemetrySavedObject } from '../../telemetry_repository'; -import { getTelemetryOptIn, getTelemetrySendUsageFrom } from '../../telemetry_config'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { getTelemetryOptIn, getTelemetrySendUsageFrom } from '../../../common/telemetry_config'; +import { UsageCollectionSetup } from '../../../../usage_collection/server'; +import { TelemetryConfigType } from '../../config'; export interface TelemetryUsageStats { opt_in_status?: boolean | null; @@ -28,21 +32,31 @@ export interface TelemetryUsageStats { last_reported?: number; } -export function createCollectorFetch(server: any) { +export interface TelemetryPluginUsageCollectorOptions { + currentKibanaVersion: string; + config$: Observable; + getSavedObjectsClient: () => ISavedObjectsRepository | undefined; +} + +export function createCollectorFetch({ + currentKibanaVersion, + config$, + getSavedObjectsClient, +}: TelemetryPluginUsageCollectorOptions) { return async function fetchUsageStats(): Promise { - const config = server.config(); - const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom'); - const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus'); - const configTelemetryOptIn = config.get('telemetry.optIn'); - const currentKibanaVersion = config.get('pkg.version'); + const { sendUsageFrom, allowChangingOptInStatus, optIn = null } = await config$ + .pipe(take(1)) + .toPromise(); + const configTelemetrySendUsageFrom = sendUsageFrom; + const configTelemetryOptIn = optIn; let telemetrySavedObject: TelemetrySavedObject = {}; try { - const { getSavedObjectsRepository } = server.savedObjects; - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const internalRepository = getSavedObjectsRepository(callWithInternalUser); - telemetrySavedObject = await getTelemetrySavedObject(internalRepository); + const internalRepository = getSavedObjectsClient()!; + telemetrySavedObject = await getTelemetrySavedObject( + new SavedObjectsClient(internalRepository) + ); } catch (err) { // no-op } @@ -65,12 +79,12 @@ export function createCollectorFetch(server: any) { export function registerTelemetryPluginUsageCollector( usageCollection: UsageCollectionSetup, - server: any + options: TelemetryPluginUsageCollectorOptions ) { const collector = usageCollection.makeUsageCollector({ type: TELEMETRY_STATS_TYPE, - isReady: () => true, - fetch: createCollectorFetch(server), + isReady: () => typeof options.getSavedObjectsClient() !== 'undefined', + fetch: createCollectorFetch(options), }); usageCollection.registerCollector(collector); diff --git a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.test.ts b/src/plugins/telemetry/server/collectors/ui_metric/index.test.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.test.ts rename to src/plugins/telemetry/server/collectors/ui_metric/index.test.ts index ddb58a7d09bbd..d6667a6384a1f 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.test.ts +++ b/src/plugins/telemetry/server/collectors/ui_metric/index.test.ts @@ -17,10 +17,10 @@ * under the License. */ -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; -import { savedObjectsRepositoryMock } from '../../../../../../core/server/mocks'; +import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/server'; +import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectorOptions } from '../../../../../../plugins/usage_collection/server/collector/collector'; +import { CollectorOptions } from '../../../../../plugins/usage_collection/server/collector/collector'; import { registerUiMetricUsageCollector } from './'; @@ -33,9 +33,12 @@ describe('telemetry_ui_metric', () => { } as any; const getUsageCollector = jest.fn(); + const registerType = jest.fn(); const callCluster = jest.fn(); - beforeAll(() => registerUiMetricUsageCollector(usageCollectionMock, getUsageCollector)); + beforeAll(() => + registerUiMetricUsageCollector(usageCollectionMock, registerType, getUsageCollector) + ); test('registered collector is set', () => { expect(collector).not.toBeUndefined(); diff --git a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.ts b/src/plugins/telemetry/server/collectors/ui_metric/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/ui_metric/index.ts rename to src/plugins/telemetry/server/collectors/ui_metric/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts similarity index 82% rename from src/legacy/core_plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts rename to src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index a7b6850b0b20a..3f6e1836cac7d 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/telemetry/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -17,9 +17,13 @@ * under the License. */ -import { ISavedObjectsRepository, SavedObjectAttributes } from 'kibana/server'; +import { + ISavedObjectsRepository, + SavedObjectAttributes, + SavedObjectsServiceSetup, +} from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { UI_METRIC_USAGE_TYPE } from '../../../common/constants'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; import { findAll } from '../find_all'; interface UIMetricsSavedObjects extends SavedObjectAttributes { @@ -28,8 +32,22 @@ interface UIMetricsSavedObjects extends SavedObjectAttributes { export function registerUiMetricUsageCollector( usageCollection: UsageCollectionSetup, + registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined ) { + registerType({ + name: 'ui-metric', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + count: { + type: 'integer', + }, + }, + }, + }); + const collector = usageCollection.makeUsageCollector({ type: UI_METRIC_USAGE_TYPE, fetch: async () => { diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts rename to src/plugins/telemetry/server/collectors/usage/ensure_deep_object.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.ts b/src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/ensure_deep_object.ts rename to src/plugins/telemetry/server/collectors/usage/ensure_deep_object.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/index.ts b/src/plugins/telemetry/server/collectors/usage/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/index.ts rename to src/plugins/telemetry/server/collectors/usage/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts similarity index 89% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts rename to src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts index 78685cd6becc8..f44603f4f19f4 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts +++ b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.test.ts @@ -18,7 +18,6 @@ */ import { writeFileSync, unlinkSync } from 'fs'; -import { Server } from 'hapi'; import { resolve } from 'path'; import { tmpdir } from 'os'; import { @@ -32,20 +31,6 @@ const mockUsageCollector = () => ({ makeUsageCollector: jest.fn().mockImplementationOnce((arg: object) => arg), }); -const serverWithConfig = (configPath: string): Server => { - return { - config: () => ({ - get: (key: string) => { - if (key !== 'telemetry.config' && key !== 'xpack.xpack_main.telemetry.config') { - throw new Error('Expected `telemetry.config`'); - } - - return configPath; - }, - }), - } as Server; -}; - describe('telemetry_usage_collector', () => { const tempDir = tmpdir(); const tempFiles = { @@ -129,11 +114,13 @@ describe('telemetry_usage_collector', () => { // note: it uses the file's path to get the directory, then looks for 'telemetry.yml' // exclusively, which is indirectly tested by passing it the wrong "file" in the same // dir - const server: Server = serverWithConfig(tempFiles.unreadable); // the `makeUsageCollector` is mocked above to return the argument passed to it const usageCollector = mockUsageCollector() as any; - const collectorOptions = createTelemetryUsageCollector(usageCollector, server); + const collectorOptions = createTelemetryUsageCollector( + usageCollector, + () => tempFiles.unreadable + ); expect(collectorOptions.type).toBe('static_telemetry'); expect(await collectorOptions.fetch({} as any)).toEqual(expectedObject); // Sending any as the callCluster client because it's not needed in this collector but TS requires it when calling it. diff --git a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts rename to src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts index 6919b6959aa8c..3daae90106e9e 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts +++ b/src/plugins/telemetry/server/collectors/usage/telemetry_usage_collector.ts @@ -18,13 +18,16 @@ */ import { accessSync, constants, readFileSync, statSync } from 'fs'; -import { Server } from 'hapi'; import { safeLoad } from 'js-yaml'; import { dirname, join } from 'path'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { getConfigPath } from '../../../../../core/server/path'; + // look for telemetry.yml in the same places we expect kibana.yml import { ensureDeepObject } from './ensure_deep_object'; -import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; /** * The maximum file size before we ignore it (note: this limit is arbitrary). @@ -77,24 +80,20 @@ export async function readTelemetryFile(path: string): Promise true, fetch: async () => { - const config = server.config(); - const configPath = config.get('telemetry.config') as string; + const configPath = getConfigPathFn(); const telemetryPath = join(dirname(configPath), 'telemetry.yml'); return await readTelemetryFile(telemetryPath); }, }); } -export function registerTelemetryUsageCollector( - usageCollection: UsageCollectionSetup, - server: Server -) { - const collector = createTelemetryUsageCollector(usageCollection, server); +export function registerTelemetryUsageCollector(usageCollection: UsageCollectionSetup) { + const collector = createTelemetryUsageCollector(usageCollection); usageCollection.registerCollector(collector); } diff --git a/src/plugins/telemetry/server/config.ts b/src/plugins/telemetry/server/config.ts new file mode 100644 index 0000000000000..7c62a37df7170 --- /dev/null +++ b/src/plugins/telemetry/server/config.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 { schema, TypeOf } from '@kbn/config-schema'; +import { ENDPOINT_VERSION } from '../common/constants'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + allowChangingOptInStatus: schema.boolean({ defaultValue: true }), + optIn: schema.conditional( + schema.siblingRef('allowChangingOptInStatus'), + schema.literal(false), + schema.maybe(schema.literal(true)), + schema.boolean({ defaultValue: true }), + { defaultValue: true } + ), + // `config` is used internally and not intended to be set + // config: Joi.string().default(getConfigPath()), TODO: Get it in some other way + banner: schema.boolean({ defaultValue: true }), + url: schema.conditional( + schema.contextRef('dev'), + schema.literal(true), + schema.string({ + defaultValue: `https://telemetry-staging.elastic.co/xpack/${ENDPOINT_VERSION}/send`, + }), + schema.string({ + defaultValue: `https://telemetry.elastic.co/xpack/${ENDPOINT_VERSION}/send`, + }) + ), + optInStatusUrl: schema.conditional( + schema.contextRef('dev'), + schema.literal(true), + schema.string({ + defaultValue: `https://telemetry-staging.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send`, + }), + schema.string({ + defaultValue: `https://telemetry.elastic.co/opt_in_status/${ENDPOINT_VERSION}/send`, + }) + ), + sendUsageFrom: schema.oneOf([schema.literal('server'), schema.literal('browser')], { + defaultValue: 'browser', + }), +}); + +export type TelemetryConfigType = TypeOf; diff --git a/src/legacy/core_plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts similarity index 55% rename from src/legacy/core_plugins/telemetry/server/fetcher.ts rename to src/plugins/telemetry/server/fetcher.ts index d30ee10066813..be85824855ff3 100644 --- a/src/legacy/core_plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -18,48 +18,109 @@ */ import moment from 'moment'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; // @ts-ignore import fetch from 'node-fetch'; -import { telemetryCollectionManager } from './collection_manager'; +import { TelemetryCollectionManagerPluginStart } from 'src/plugins/telemetry_collection_manager/server'; +import { + PluginInitializerContext, + Logger, + SavedObjectsClientContract, + SavedObjectsClient, + CoreStart, + ICustomClusterClient, +} from '../../../core/server'; import { getTelemetryOptIn, getTelemetrySendUsageFrom, getTelemetryFailureDetails, -} from './telemetry_config'; +} from '../common/telemetry_config'; import { getTelemetrySavedObject, updateTelemetrySavedObject } from './telemetry_repository'; import { REPORT_INTERVAL_MS } from '../common/constants'; +import { TelemetryConfigType } from './config'; + +export interface FetcherTaskDepsStart { + telemetryCollectionManager: TelemetryCollectionManagerPluginStart; +} export class FetcherTask { private readonly initialCheckDelayMs = 60 * 1000 * 5; private readonly checkIntervalMs = 60 * 1000 * 60 * 12; + private readonly config$: Observable; + private readonly currentKibanaVersion: string; + private readonly logger: Logger; private intervalId?: NodeJS.Timeout; private lastReported?: number; - private currentVersion: string; private isSending = false; - private server: any; + private internalRepository?: SavedObjectsClientContract; + private telemetryCollectionManager?: TelemetryCollectionManagerPluginStart; + private elasticsearchClient?: ICustomClusterClient; + + constructor(initializerContext: PluginInitializerContext) { + this.config$ = initializerContext.config.create(); + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + this.logger = initializerContext.logger.get('fetcher'); + } + + public start( + { savedObjects, elasticsearch }: CoreStart, + { telemetryCollectionManager }: FetcherTaskDepsStart + ) { + this.internalRepository = new SavedObjectsClient(savedObjects.createInternalRepository()); + this.telemetryCollectionManager = telemetryCollectionManager; + this.elasticsearchClient = elasticsearch.legacy.createClient('telemetry-fetcher'); + + setTimeout(() => { + this.sendIfDue(); + this.intervalId = setInterval(() => this.sendIfDue(), this.checkIntervalMs); + }, this.initialCheckDelayMs); + } + + public stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + if (this.elasticsearchClient) { + this.elasticsearchClient.close(); + } + } - constructor(server: any) { - this.server = server; - this.currentVersion = this.server.config().get('pkg.version'); + private async sendIfDue() { + if (this.isSending) { + return; + } + const telemetryConfig = await this.getCurrentConfigs(); + if (!this.shouldSendReport(telemetryConfig)) { + return; + } + + try { + this.isSending = true; + const clusters = await this.fetchTelemetry(); + const { telemetryUrl } = telemetryConfig; + for (const cluster of clusters) { + await this.sendTelemetry(telemetryUrl, cluster); + } + + await this.updateLastReported(); + } catch (err) { + await this.updateReportFailure(telemetryConfig); + + this.logger.warn(`Error sending telemetry usage data: ${err}`); + } + this.isSending = false; } - private getInternalRepository = () => { - const { getSavedObjectsRepository } = this.server.savedObjects; - const { callWithInternalUser } = this.server.plugins.elasticsearch.getCluster('admin'); - const internalRepository = getSavedObjectsRepository(callWithInternalUser); - return internalRepository; - }; - - private getCurrentConfigs = async () => { - const internalRepository = this.getInternalRepository(); - const telemetrySavedObject = await getTelemetrySavedObject(internalRepository); - const config = this.server.config(); - const currentKibanaVersion = config.get('pkg.version'); - const configTelemetrySendUsageFrom = config.get('telemetry.sendUsageFrom'); - const allowChangingOptInStatus = config.get('telemetry.allowChangingOptInStatus'); - const configTelemetryOptIn = config.get('telemetry.optIn'); - const telemetryUrl = config.get('telemetry.url') as string; - const { failureCount, failureVersion } = await getTelemetryFailureDetails({ + private async getCurrentConfigs() { + const telemetrySavedObject = await getTelemetrySavedObject(this.internalRepository!); + const config = await this.config$.pipe(take(1)).toPromise(); + const currentKibanaVersion = this.currentKibanaVersion; + const configTelemetrySendUsageFrom = config.sendUsageFrom; + const allowChangingOptInStatus = config.allowChangingOptInStatus; + const configTelemetryOptIn = typeof config.optIn === 'undefined' ? null : config.optIn; + const telemetryUrl = config.url; + const { failureCount, failureVersion } = getTelemetryFailureDetails({ telemetrySavedObject, }); @@ -78,33 +139,30 @@ export class FetcherTask { failureCount, failureVersion, }; - }; + } - private updateLastReported = async () => { - const internalRepository = this.getInternalRepository(); + private async updateLastReported() { this.lastReported = Date.now(); - updateTelemetrySavedObject(internalRepository, { + updateTelemetrySavedObject(this.internalRepository!, { reportFailureCount: 0, lastReported: this.lastReported, }); - }; - - private updateReportFailure = async ({ failureCount }: { failureCount: number }) => { - const internalRepository = this.getInternalRepository(); + } - updateTelemetrySavedObject(internalRepository, { + private async updateReportFailure({ failureCount }: { failureCount: number }) { + updateTelemetrySavedObject(this.internalRepository!, { reportFailureCount: failureCount + 1, - reportFailureVersion: this.currentVersion, + reportFailureVersion: this.currentKibanaVersion, }); - }; + } - private shouldSendReport = ({ + private shouldSendReport({ telemetryOptIn, telemetrySendUsageFrom, reportFailureCount, currentVersion, reportFailureVersion, - }: any) => { + }: any) { if (reportFailureCount > 2 && reportFailureVersion === currentVersion) { return false; } @@ -115,21 +173,20 @@ export class FetcherTask { } } return false; - }; + } - private fetchTelemetry = async () => { - return await telemetryCollectionManager.getStats({ + private async fetchTelemetry() { + return await this.telemetryCollectionManager!.getStats({ unencrypted: false, - server: this.server, start: moment() .subtract(20, 'minutes') .toISOString(), end: moment().toISOString(), }); - }; + } - private sendTelemetry = async (url: string, cluster: any): Promise => { - this.server.log(['debug', 'telemetry', 'fetcher'], `Sending usage stats.`); + private async sendTelemetry(url: string, cluster: any): Promise { + this.logger.debug(`Sending usage stats.`); /** * send OPTIONS before sending usage data. * OPTIONS is less intrusive as it does not contain any payload and is used here to check if the endpoint is reachable. @@ -142,47 +199,5 @@ export class FetcherTask { method: 'post', body: cluster, }); - }; - - private sendIfDue = async () => { - if (this.isSending) { - return; - } - const telemetryConfig = await this.getCurrentConfigs(); - if (!this.shouldSendReport(telemetryConfig)) { - return; - } - - try { - this.isSending = true; - const clusters = await this.fetchTelemetry(); - const { telemetryUrl } = telemetryConfig; - for (const cluster of clusters) { - await this.sendTelemetry(telemetryUrl, cluster); - } - - await this.updateLastReported(); - } catch (err) { - await this.updateReportFailure(telemetryConfig); - - this.server.log( - ['warning', 'telemetry', 'fetcher'], - `Error sending telemetry usage data: ${err}` - ); - } - this.isSending = false; - }; - - public start = () => { - setTimeout(() => { - this.sendIfDue(); - this.intervalId = setInterval(() => this.sendIfDue(), this.checkIntervalMs); - }, this.initialCheckDelayMs); - }; - - public stop = () => { - if (this.intervalId) { - clearInterval(this.intervalId); - } - }; + } } diff --git a/src/legacy/core_plugins/telemetry/server/handle_old_settings/handle_old_settings.ts b/src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts similarity index 74% rename from src/legacy/core_plugins/telemetry/server/handle_old_settings/handle_old_settings.ts rename to src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts index b28a01bffa44d..3562ba452104c 100644 --- a/src/legacy/core_plugins/telemetry/server/handle_old_settings/handle_old_settings.ts +++ b/src/plugins/telemetry/server/handle_old_settings/handle_old_settings.ts @@ -26,27 +26,25 @@ * @return {Boolean} {@code true} if the banner should still be displayed. {@code false} if the banner should not be displayed. */ -import { Server } from 'hapi'; +import { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; import { CONFIG_TELEMETRY } from '../../common/constants'; import { updateTelemetrySavedObject } from '../telemetry_repository'; const CONFIG_ALLOW_REPORT = 'xPackMonitoring:allowReport'; -export async function handleOldSettings(server: Server) { - const { getSavedObjectsRepository } = server.savedObjects; - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const savedObjectsClient = getSavedObjectsRepository(callWithInternalUser); - const uiSettings = server.uiSettingsServiceFactory({ savedObjectsClient }); - - const oldTelemetrySetting = await uiSettings.get(CONFIG_TELEMETRY); - const oldAllowReportSetting = await uiSettings.get(CONFIG_ALLOW_REPORT); +export async function handleOldSettings( + savedObjectsClient: SavedObjectsClientContract, + uiSettingsClient: IUiSettingsClient +) { + const oldTelemetrySetting = await uiSettingsClient.get(CONFIG_TELEMETRY); + const oldAllowReportSetting = await uiSettingsClient.get(CONFIG_ALLOW_REPORT); let legacyOptInValue = null; if (typeof oldTelemetrySetting === 'boolean') { legacyOptInValue = oldTelemetrySetting; } else if ( typeof oldAllowReportSetting === 'boolean' && - uiSettings.isOverridden(CONFIG_ALLOW_REPORT) + uiSettingsClient.isOverridden(CONFIG_ALLOW_REPORT) ) { legacyOptInValue = oldAllowReportSetting; } diff --git a/src/legacy/core_plugins/telemetry/server/handle_old_settings/index.ts b/src/plugins/telemetry/server/handle_old_settings/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/handle_old_settings/index.ts rename to src/plugins/telemetry/server/handle_old_settings/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/index.ts b/src/plugins/telemetry/server/index.ts similarity index 60% rename from src/legacy/core_plugins/telemetry/server/index.ts rename to src/plugins/telemetry/server/index.ts index 85d7d80234ffc..d048c8f5e9427 100644 --- a/src/legacy/core_plugins/telemetry/server/index.ts +++ b/src/plugins/telemetry/server/index.ts @@ -17,15 +17,34 @@ * under the License. */ -import { PluginInitializerContext } from 'src/core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from 'kibana/server'; import { TelemetryPlugin } from './plugin'; import * as constants from '../common/constants'; +import { configSchema, TelemetryConfigType } from './config'; export { FetcherTask } from './fetcher'; -export { replaceTelemetryInjectedVars } from './telemetry_config'; export { handleOldSettings } from './handle_old_settings'; -export { telemetryCollectionManager } from './collection_manager'; -export { PluginsSetup } from './plugin'; -export const telemetryPlugin = (initializerContext: PluginInitializerContext) => +export { TelemetryPluginsSetup } from './plugin'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + enabled: true, + url: true, + banner: true, + allowChangingOptInStatus: true, + optIn: true, + optInStatusUrl: true, + sendUsageFrom: true, + }, +}; + +export const plugin = (initializerContext: PluginInitializerContext) => new TelemetryPlugin(initializerContext); export { constants }; +export { + getClusterUuids, + getLocalLicense, + getLocalStats, + TelemetryLocalStats, +} from './telemetry_collection'; diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts new file mode 100644 index 0000000000000..af512d234a7dc --- /dev/null +++ b/src/plugins/telemetry/server/plugin.ts @@ -0,0 +1,168 @@ +/* + * 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + TelemetryCollectionManagerPluginSetup, + TelemetryCollectionManagerPluginStart, +} from 'src/plugins/telemetry_collection_manager/server'; +import { + CoreSetup, + PluginInitializerContext, + ISavedObjectsRepository, + CoreStart, + IUiSettingsClient, + SavedObjectsClient, + Plugin, + Logger, +} from '../../../core/server'; +import { registerRoutes } from './routes'; +import { registerCollection } from './telemetry_collection'; +import { + registerUiMetricUsageCollector, + registerTelemetryUsageCollector, + registerTelemetryPluginUsageCollector, + registerManagementUsageCollector, + registerApplicationUsageCollector, +} from './collectors'; +import { TelemetryConfigType } from './config'; +import { FetcherTask } from './fetcher'; +import { handleOldSettings } from './handle_old_settings'; + +export interface TelemetryPluginsSetup { + usageCollection: UsageCollectionSetup; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; +} + +export interface TelemetryPluginsStart { + telemetryCollectionManager: TelemetryCollectionManagerPluginStart; +} + +type SavedObjectsRegisterType = CoreSetup['savedObjects']['registerType']; + +export class TelemetryPlugin implements Plugin { + private readonly logger: Logger; + private readonly currentKibanaVersion: string; + private readonly config$: Observable; + private readonly isDev: boolean; + private readonly fetcherTask: FetcherTask; + private savedObjectsClient?: ISavedObjectsRepository; + private uiSettingsClient?: IUiSettingsClient; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.isDev = initializerContext.env.mode.dev; + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + this.config$ = initializerContext.config.create(); + this.fetcherTask = new FetcherTask({ + ...initializerContext, + logger: this.logger, + }); + } + + public async setup( + core: CoreSetup, + { usageCollection, telemetryCollectionManager }: TelemetryPluginsSetup + ) { + const currentKibanaVersion = this.currentKibanaVersion; + const config$ = this.config$; + const isDev = this.isDev; + + registerCollection(telemetryCollectionManager, core.elasticsearch.dataClient); + const router = core.http.createRouter(); + + registerRoutes({ + config$, + currentKibanaVersion, + isDev, + router, + telemetryCollectionManager, + }); + + this.registerMappings(opts => core.savedObjects.registerType(opts)); + this.registerUsageCollectors(usageCollection, opts => core.savedObjects.registerType(opts)); + } + + public async start(core: CoreStart, { telemetryCollectionManager }: TelemetryPluginsStart) { + const { savedObjects, uiSettings } = core; + this.savedObjectsClient = savedObjects.createInternalRepository(); + const savedObjectsClient = new SavedObjectsClient(this.savedObjectsClient); + this.uiSettingsClient = uiSettings.asScopedToClient(savedObjectsClient); + + try { + await handleOldSettings(savedObjectsClient, this.uiSettingsClient); + } catch (error) { + this.logger.warn('Unable to update legacy telemetry configs.'); + } + + this.fetcherTask.start(core, { telemetryCollectionManager }); + } + + private registerMappings(registerType: SavedObjectsRegisterType) { + registerType({ + name: 'telemetry', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: { + enabled: { + type: 'boolean', + }, + sendUsageFrom: { + type: 'keyword', + }, + lastReported: { + type: 'date', + }, + lastVersionChecked: { + type: 'keyword', + }, + userHasSeenNotice: { + type: 'boolean', + }, + reportFailureCount: { + type: 'integer', + }, + reportFailureVersion: { + type: 'keyword', + }, + }, + }, + }); + } + + private registerUsageCollectors( + usageCollection: UsageCollectionSetup, + registerType: SavedObjectsRegisterType + ) { + const getSavedObjectsClient = () => this.savedObjectsClient; + const getUiSettingsClient = () => this.uiSettingsClient; + + registerTelemetryPluginUsageCollector(usageCollection, { + currentKibanaVersion: this.currentKibanaVersion, + config$: this.config$, + getSavedObjectsClient, + }); + registerTelemetryUsageCollector(usageCollection); + registerManagementUsageCollector(usageCollection, getUiSettingsClient); + registerUiMetricUsageCollector(usageCollection, registerType, getSavedObjectsClient); + registerApplicationUsageCollector(usageCollection, registerType, getSavedObjectsClient); + } +} diff --git a/src/legacy/core_plugins/telemetry/server/routes/index.ts b/src/plugins/telemetry/server/routes/index.ts similarity index 61% rename from src/legacy/core_plugins/telemetry/server/routes/index.ts rename to src/plugins/telemetry/server/routes/index.ts index 31ff1682d6806..ad84cb9d2665d 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/index.ts +++ b/src/plugins/telemetry/server/routes/index.ts @@ -17,22 +17,27 @@ * under the License. */ -import { Legacy } from 'kibana'; -import { CoreSetup } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { IRouter } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { registerTelemetryOptInRoutes } from './telemetry_opt_in'; import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats'; import { registerTelemetryOptInStatsRoutes } from './telemetry_opt_in_stats'; import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_notice'; +import { TelemetryConfigType } from '../config'; interface RegisterRoutesParams { - core: CoreSetup; + isDev: boolean; + config$: Observable; currentKibanaVersion: string; - server: Legacy.Server; + router: IRouter; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; } -export function registerRoutes({ core, currentKibanaVersion, server }: RegisterRoutesParams) { - registerTelemetryOptInRoutes({ core, currentKibanaVersion, server }); - registerTelemetryUsageStatsRoutes(server); - registerTelemetryOptInStatsRoutes(server); - registerTelemetryUserHasSeenNotice(server); +export function registerRoutes(options: RegisterRoutesParams) { + const { isDev, telemetryCollectionManager, router } = options; + registerTelemetryOptInRoutes(options); + registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev); + registerTelemetryOptInStatsRoutes(router, telemetryCollectionManager); + registerTelemetryUserHasSeenNotice(router); } diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts new file mode 100644 index 0000000000000..e65ade0ab8aaa --- /dev/null +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts @@ -0,0 +1,101 @@ +/* + * 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 moment from 'moment'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; +import { getTelemetryAllowChangingOptInStatus } from '../../common/telemetry_config'; +import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats'; + +import { + TelemetrySavedObjectAttributes, + updateTelemetrySavedObject, + getTelemetrySavedObject, +} from '../telemetry_repository'; +import { TelemetryConfigType } from '../config'; + +interface RegisterOptInRoutesParams { + currentKibanaVersion: string; + router: IRouter; + config$: Observable; + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; +} + +export function registerTelemetryOptInRoutes({ + config$, + router, + currentKibanaVersion, + telemetryCollectionManager, +}: RegisterOptInRoutesParams) { + router.post( + { + path: '/api/telemetry/v2/optIn', + validate: { + body: schema.object({ enabled: schema.boolean() }), + }, + }, + async (context, req, res) => { + const newOptInStatus = req.body.enabled; + const attributes: TelemetrySavedObjectAttributes = { + enabled: newOptInStatus, + lastVersionChecked: currentKibanaVersion, + }; + const config = await config$.pipe(take(1)).toPromise(); + const telemetrySavedObject = await getTelemetrySavedObject(context.core.savedObjects.client); + + if (telemetrySavedObject === false) { + // If we get false, we couldn't get the saved object due to lack of permissions + // so we can assume the user won't be able to update it either + return res.forbidden(); + } + + const configTelemetryAllowChangingOptInStatus = config.allowChangingOptInStatus; + const allowChangingOptInStatus = getTelemetryAllowChangingOptInStatus({ + telemetrySavedObject, + configTelemetryAllowChangingOptInStatus, + }); + if (!allowChangingOptInStatus) { + return res.badRequest({ + body: JSON.stringify({ error: 'Not allowed to change Opt-in Status.' }), + }); + } + + if (config.sendUsageFrom === 'server') { + const optInStatusUrl = config.optInStatusUrl; + await sendTelemetryOptInStatus( + telemetryCollectionManager, + { optInStatusUrl, newOptInStatus }, + { + start: moment() + .subtract(20, 'minutes') + .toISOString(), + end: moment().toISOString(), + unencrypted: false, + } + ); + } + + await updateTelemetrySavedObject(context.core.savedObjects.client, attributes); + return res.ok({}); + } + ); +} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts similarity index 65% rename from src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts rename to src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts index e64f3f6ff8a94..3263c6d49523c 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_opt_in_stats.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in_stats.ts @@ -19,10 +19,14 @@ // @ts-ignore import fetch from 'node-fetch'; -import Joi from 'joi'; import moment from 'moment'; -import { Legacy } from 'kibana'; -import { telemetryCollectionManager, StatsGetterConfig } from '../collection_manager'; + +import { IRouter } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { + TelemetryCollectionManagerPluginSetup, + StatsGetterConfig, +} from 'src/plugins/telemetry_collection_manager/server'; interface SendTelemetryOptInStatusConfig { optInStatusUrl: string; @@ -30,6 +34,7 @@ interface SendTelemetryOptInStatusConfig { } export async function sendTelemetryOptInStatus( + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, config: SendTelemetryOptInStatusConfig, statsGetterConfig: StatsGetterConfig ) { @@ -45,41 +50,42 @@ export async function sendTelemetryOptInStatus( }); } -export function registerTelemetryOptInStatsRoutes(server: Legacy.Server) { - server.route({ - method: 'POST', - path: '/api/telemetry/v2/clusters/_opt_in_stats', - options: { +export function registerTelemetryOptInStatsRoutes( + router: IRouter, + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup +) { + router.post( + { + path: '/api/telemetry/v2/clusters/_opt_in_stats', validate: { - payload: Joi.object({ - enabled: Joi.bool().required(), - unencrypted: Joi.bool().default(true), + body: schema.object({ + enabled: schema.boolean(), + unencrypted: schema.boolean({ defaultValue: true }), }), }, }, - handler: async (req: any, h: any) => { + async (context, req, res) => { try { - const newOptInStatus = req.payload.enabled; - const unencrypted = req.payload.unencrypted; - const statsGetterConfig = { + const newOptInStatus = req.body.enabled; + const unencrypted = req.body.unencrypted; + + const statsGetterConfig: StatsGetterConfig = { start: moment() .subtract(20, 'minutes') .toISOString(), end: moment().toISOString(), - server: req.server, - req, unencrypted, + request: req, }; const optInStatus = await telemetryCollectionManager.getOptInStats( newOptInStatus, statsGetterConfig ); - - return h.response(optInStatus).code(200); + return res.ok({ body: optInStatus }); } catch (err) { - return h.response([]).code(200); + return res.ok({ body: [] }); } - }, - }); + } + ); } diff --git a/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts new file mode 100644 index 0000000000000..15d4c0ca2fa55 --- /dev/null +++ b/src/plugins/telemetry/server/routes/telemetry_usage_stats.ts @@ -0,0 +1,82 @@ +/* + * 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 moment from 'moment'; +import { schema } from '@kbn/config-schema'; +import { TypeOptions } from '@kbn/config-schema/target/types/types'; +import { IRouter } from 'kibana/server'; +import { + TelemetryCollectionManagerPluginSetup, + StatsGetterConfig, +} from 'src/plugins/telemetry_collection_manager/server'; + +const validate: TypeOptions['validate'] = value => { + if (!moment(value).isValid()) { + return `${value} is not a valid date`; + } +}; + +const dateSchema = schema.oneOf([schema.string({ validate }), schema.number({ validate })]); + +export function registerTelemetryUsageStatsRoutes( + router: IRouter, + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, + isDev: boolean +) { + router.post( + { + path: '/api/telemetry/v2/clusters/_stats', + validate: { + body: schema.object({ + unencrypted: schema.boolean({ defaultValue: false }), + timeRange: schema.object({ + min: dateSchema, + max: dateSchema, + }), + }), + }, + }, + async (context, req, res) => { + const start = moment(req.body.timeRange.min).toISOString(); + const end = moment(req.body.timeRange.max).toISOString(); + const unencrypted = req.body.unencrypted; + + try { + const statsConfig: StatsGetterConfig = { + unencrypted, + start, + end, + request: req, + }; + const stats = await telemetryCollectionManager.getStats(statsConfig); + return res.ok({ body: stats }); + } catch (err) { + if (isDev) { + // don't ignore errors when running in dev mode + throw err; + } + if (unencrypted && err.status === 403) { + return res.forbidden(); + } + // ignore errors and return empty set + return res.ok({ body: [] }); + } + } + ); +} diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts b/src/plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts similarity index 65% rename from src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts rename to src/plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts index 665e6d9aaeb75..45a5147107f02 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts +++ b/src/plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts @@ -17,8 +17,7 @@ * under the License. */ -import { Legacy } from 'kibana'; -import { Request } from 'hapi'; +import { IRouter } from 'kibana/server'; import { TelemetrySavedObject, TelemetrySavedObjectAttributes, @@ -26,19 +25,14 @@ import { updateTelemetrySavedObject, } from '../telemetry_repository'; -const getInternalRepository = (server: Legacy.Server) => { - const { getSavedObjectsRepository } = server.savedObjects; - const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); - const internalRepository = getSavedObjectsRepository(callWithInternalUser); - return internalRepository; -}; - -export function registerTelemetryUserHasSeenNotice(server: Legacy.Server) { - server.route({ - method: 'PUT', - path: '/api/telemetry/v2/userHasSeenNotice', - handler: async (req: Request): Promise => { - const internalRepository = getInternalRepository(server); +export function registerTelemetryUserHasSeenNotice(router: IRouter) { + router.put( + { + path: '/api/telemetry/v2/userHasSeenNotice', + validate: false, + }, + async (context, req, res) => { + const internalRepository = context.core.savedObjects.client; const telemetrySavedObject: TelemetrySavedObject = await getTelemetrySavedObject( internalRepository ); @@ -50,7 +44,7 @@ export function registerTelemetryUserHasSeenNotice(server: Legacy.Server) { }; await updateTelemetrySavedObject(internalRepository, updatedAttributes); - return updatedAttributes; - }, - }); + return res.ok({ body: updatedAttributes }); + } + ); } diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js rename to src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_info.js diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js rename to src/plugins/telemetry/server/telemetry_collection/__tests__/get_cluster_stats.js diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js similarity index 96% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js rename to src/plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js index 94627953f1ac6..ac3c5307adcf6 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js +++ b/src/plugins/telemetry/server/telemetry_collection/__tests__/get_local_stats.js @@ -128,9 +128,14 @@ describe('get_local_stats', () => { }, }; + const context = { + logger: console, + version: '8.0.0', + }; + describe('handleLocalStats', () => { it('returns expected object without xpack and kibana data', () => { - const result = handleLocalStats(getMockServer(), clusterInfo, clusterStats); + const result = handleLocalStats(clusterInfo, clusterStats, void 0, context); expect(result.cluster_uuid).to.eql(combinedStatsResult.cluster_uuid); expect(result.cluster_name).to.eql(combinedStatsResult.cluster_name); expect(result.cluster_stats).to.eql(combinedStatsResult.cluster_stats); @@ -141,7 +146,7 @@ describe('get_local_stats', () => { }); it('returns expected object with xpack', () => { - const result = handleLocalStats(getMockServer(), clusterInfo, clusterStats); + const result = handleLocalStats(clusterInfo, clusterStats, void 0, context); const { stack_stats: stack, ...cluster } = result; expect(cluster.collection).to.be(combinedStatsResult.collection); expect(cluster.cluster_uuid).to.be(combinedStatsResult.cluster_uuid); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/constants.ts b/src/plugins/telemetry/server/telemetry_collection/constants.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/constants.ts rename to src/plugins/telemetry/server/telemetry_collection/constants.ts diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_info.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts similarity index 92% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_info.ts rename to src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts index 67812457ed4ec..d5f0d2d8c9598 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_info.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts @@ -17,7 +17,7 @@ * under the License. */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { APICaller } from 'kibana/server'; // This can be removed when the ES client improves the types export interface ESClusterInfo { @@ -43,6 +43,6 @@ export interface ESClusterInfo { * * @param {function} callCluster The callWithInternalUser handler (exposed for testing) */ -export function getClusterInfo(callCluster: CallCluster) { +export function getClusterInfo(callCluster: APICaller) { return callCluster('info'); } diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts similarity index 86% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts rename to src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts index 4abd95f0cf66d..89e2cae777985 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_cluster_stats.ts @@ -17,15 +17,15 @@ * under the License. */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { ClusterDetailsGetter } from 'src/plugins/telemetry_collection_manager/server'; +import { APICaller } from 'kibana/server'; import { TIMEOUT } from './constants'; -import { ClusterDetailsGetter } from '../collection_manager'; /** * Get the cluster stats from the connected cluster. * * This is the equivalent to GET /_cluster/stats?timeout=30s. */ -export async function getClusterStats(callCluster: CallCluster) { +export async function getClusterStats(callCluster: APICaller) { return await callCluster('cluster.stats', { timeout: TIMEOUT, }); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_kibana.ts b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts similarity index 79% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_kibana.ts rename to src/plugins/telemetry/server/telemetry_collection/get_kibana.ts index 537d5a85911cd..86c6731e11d37 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_kibana.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_kibana.ts @@ -19,7 +19,8 @@ import { omit } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { APICaller } from 'kibana/server'; +import { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server'; export interface KibanaUsageStats { kibana: { @@ -37,12 +38,12 @@ export interface KibanaUsageStats { [plugin: string]: any; } -export function handleKibanaStats(server: any, response?: KibanaUsageStats) { +export function handleKibanaStats( + { logger, version: serverVersion }: StatsCollectionContext, + response?: KibanaUsageStats +) { if (!response) { - server.log( - ['warning', 'telemetry', 'local-stats'], - 'No Kibana stats returned from usage collectors' - ); + logger.warn('No Kibana stats returned from usage collectors'); return; } @@ -60,10 +61,7 @@ export function handleKibanaStats(server: any, response?: KibanaUsageStats) { }; }, {}); - const version = server - .config() - .get('pkg.version') - .replace(/-snapshot/i, ''); + const version = serverVersion.replace(/-snapshot/i, ''); // Shouldn't we better maintain the -snapshot so we can differentiate between actual final releases and snapshots? // combine core stats (os types, saved objects) with plugin usage stats // organize the object into the same format as monitoring-enabled telemetry @@ -79,7 +77,7 @@ export function handleKibanaStats(server: any, response?: KibanaUsageStats) { export async function getKibana( usageCollection: UsageCollectionSetup, - callWithInternalUser: CallCluster + callWithInternalUser: APICaller ): Promise { const usage = await usageCollection.bulkFetch(callWithInternalUser); return usageCollection.toObject(usage); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_license.ts similarity index 80% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license.ts rename to src/plugins/telemetry/server/telemetry_collection/get_local_license.ts index 589392ffb6095..ad0666c7ad153 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_license.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_license.ts @@ -17,26 +17,12 @@ * under the License. */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; -import { LicenseGetter } from '../collection_manager'; +import { APICaller } from 'kibana/server'; +import { ESLicense, LicenseGetter } from 'src/plugins/telemetry_collection_manager/server'; -// From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html -export interface ESLicense { - status: string; - uid: string; - type: string; - issue_date: string; - issue_date_in_millis: number; - expiry_date: string; - expirty_date_in_millis: number; - max_nodes: number; - issued_to: string; - issuer: string; - start_date_in_millis: number; -} let cachedLicense: ESLicense | undefined; -function fetchLicense(callCluster: CallCluster, local: boolean) { +function fetchLicense(callCluster: APICaller, local: boolean) { return callCluster<{ license: ESLicense }>('transport.request', { method: 'GET', path: '/_license', @@ -55,7 +41,7 @@ function fetchLicense(callCluster: CallCluster, local: boolean) { * * Like any X-Pack related API, X-Pack must installed for this to work. */ -async function getLicenseFromLocalOrMaster(callCluster: CallCluster) { +async function getLicenseFromLocalOrMaster(callCluster: APICaller) { // Fetching the local license is cheaper than getting it from the master and good enough const { license } = await fetchLicense(callCluster, true).catch(async err => { if (cachedLicense) { diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats.ts b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts similarity index 82% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats.ts rename to src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts index d99710deb1cbc..19d5c2970361c 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/get_local_stats.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_local_stats.ts @@ -17,10 +17,13 @@ * under the License. */ +import { + StatsGetter, + StatsCollectionContext, +} from 'src/plugins/telemetry_collection_manager/server'; import { getClusterInfo, ESClusterInfo } from './get_cluster_info'; import { getClusterStats } from './get_cluster_stats'; import { getKibana, handleKibanaStats, KibanaUsageStats } from './get_kibana'; -import { StatsGetter } from '../collection_manager'; /** * Handle the separate local calls by combining them into a single object response that looks like the @@ -32,10 +35,10 @@ import { StatsGetter } from '../collection_manager'; * @param {Object} kibana The Kibana Usage stats */ export function handleLocalStats( - server: any, { cluster_name, cluster_uuid, version }: ESClusterInfo, { _nodes, cluster_name: clusterName, ...clusterStats }: any, - kibana: KibanaUsageStats + kibana: KibanaUsageStats, + context: StatsCollectionContext ) { return { timestamp: new Date().toISOString(), @@ -45,7 +48,7 @@ export function handleLocalStats( cluster_stats: clusterStats, collection: 'local', stack_stats: { - kibana: handleKibanaStats(server, kibana), + kibana: handleKibanaStats(context, kibana), }, }; } @@ -55,8 +58,12 @@ export type TelemetryLocalStats = ReturnType; /** * Get statistics for all products joined by Elasticsearch cluster. */ -export const getLocalStats: StatsGetter = async (clustersDetails, config) => { - const { server, callCluster, usageCollection } = config; +export const getLocalStats: StatsGetter<{}, TelemetryLocalStats> = async ( + clustersDetails, + config, + context +) => { + const { callCluster, usageCollection } = config; return await Promise.all( clustersDetails.map(async clustersDetail => { @@ -65,7 +72,7 @@ export const getLocalStats: StatsGetter = async (clustersDe getClusterStats(callCluster), // cluster stats (not to be confused with cluster _state_) getKibana(usageCollection, callCluster), ]); - return handleLocalStats(server, clusterInfo, clusterStats, kibana); + return handleLocalStats(clusterInfo, clusterStats, kibana, context); }) ); }; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/index.ts b/src/plugins/telemetry/server/telemetry_collection/index.ts similarity index 87% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/index.ts rename to src/plugins/telemetry/server/telemetry_collection/index.ts index 9ac94216c21bc..377ddab7b877c 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/index.ts +++ b/src/plugins/telemetry/server/telemetry_collection/index.ts @@ -17,6 +17,7 @@ * under the License. */ -export { getLocalStats } from './get_local_stats'; +export { getLocalStats, TelemetryLocalStats } from './get_local_stats'; +export { getLocalLicense } from './get_local_license'; export { getClusterUuids } from './get_cluster_stats'; export { registerCollection } from './register_collection'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_collection/register_collection.ts b/src/plugins/telemetry/server/telemetry_collection/register_collection.ts similarity index 86% rename from src/legacy/core_plugins/telemetry/server/telemetry_collection/register_collection.ts rename to src/plugins/telemetry/server/telemetry_collection/register_collection.ts index 6580b47dba08e..833fd9f7fd5bc 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_collection/register_collection.ts +++ b/src/plugins/telemetry/server/telemetry_collection/register_collection.ts @@ -36,14 +36,18 @@ * under the License. */ -import { telemetryCollectionManager } from '../collection_manager'; +import { IClusterClient } from 'kibana/server'; +import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { getLocalStats } from './get_local_stats'; import { getClusterUuids } from './get_cluster_stats'; import { getLocalLicense } from './get_local_license'; -export function registerCollection() { +export function registerCollection( + telemetryCollectionManager: TelemetryCollectionManagerPluginSetup, + esCluster: IClusterClient +) { telemetryCollectionManager.setCollection({ - esCluster: 'data', + esCluster, title: 'local', priority: 0, statsGetter: getLocalStats, diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts similarity index 95% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts rename to src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts index 7cc177878de4d..ebb583e88d4e1 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts +++ b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.test.ts @@ -18,7 +18,7 @@ */ import { getTelemetrySavedObject } from './get_telemetry_saved_object'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; describe('getTelemetrySavedObject', () => { it('returns null when saved object not found', async () => { @@ -79,7 +79,7 @@ function getCallGetTelemetrySavedObjectParams( async function callGetTelemetrySavedObject(params: CallGetTelemetrySavedObjectParams) { const savedObjectsClient = getMockSavedObjectsClient(params); - return await getTelemetrySavedObject(savedObjectsClient); + return await getTelemetrySavedObject(savedObjectsClient as any); } const SavedObjectForbiddenMessage = 'savedObjectForbidden'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts similarity index 81% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts rename to src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts index 91965ef201ecb..1b3ea093fb3d8 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts +++ b/src/plugins/telemetry/server/telemetry_repository/get_telemetry_saved_object.ts @@ -17,13 +17,16 @@ * under the License. */ -import { TelemetrySavedObjectAttributes } from './'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { SavedObjectsErrorHelpers, SavedObjectsClientContract } from '../../../../core/server'; +import { TelemetrySavedObject } from './'; -export type TelemetrySavedObject = TelemetrySavedObjectAttributes | null | false; -type GetTelemetrySavedObject = (repository: any) => Promise; +type GetTelemetrySavedObject = ( + repository: SavedObjectsClientContract +) => Promise; -export const getTelemetrySavedObject: GetTelemetrySavedObject = async (repository: any) => { +export const getTelemetrySavedObject: GetTelemetrySavedObject = async ( + repository: SavedObjectsClientContract +) => { try { const { attributes } = await repository.get('telemetry', 'telemetry'); return attributes; diff --git a/src/plugins/telemetry/server/telemetry_repository/index.ts b/src/plugins/telemetry/server/telemetry_repository/index.ts new file mode 100644 index 0000000000000..b98fa6971e481 --- /dev/null +++ b/src/plugins/telemetry/server/telemetry_repository/index.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { getTelemetrySavedObject } from './get_telemetry_saved_object'; +export { updateTelemetrySavedObject } from './update_telemetry_saved_object'; +export { + TelemetrySavedObject, + TelemetrySavedObjectAttributes, +} from '../../common/telemetry_config/types'; diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts b/src/plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts similarity index 89% rename from src/legacy/core_plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts rename to src/plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts index b66e01faaa6bc..64a2f675e4fd8 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts +++ b/src/plugins/telemetry/server/telemetry_repository/update_telemetry_saved_object.ts @@ -17,11 +17,11 @@ * under the License. */ +import { SavedObjectsErrorHelpers, SavedObjectsClientContract } from '../../../../core/server'; import { TelemetrySavedObjectAttributes } from './'; -import { SavedObjectsErrorHelpers } from '../../../../../core/server'; export async function updateTelemetrySavedObject( - savedObjectsClient: any, + savedObjectsClient: SavedObjectsClientContract, savedObjectAttributes: TelemetrySavedObjectAttributes ) { try { diff --git a/src/plugins/telemetry_collection_manager/README.md b/src/plugins/telemetry_collection_manager/README.md new file mode 100644 index 0000000000000..3ded16e08a7aa --- /dev/null +++ b/src/plugins/telemetry_collection_manager/README.md @@ -0,0 +1,7 @@ +# Telemetry Collection Manager + +Telemetry's collection manager to go through all the telemetry sources when fetching it before reporting. + +It has been split into a separate plugin because the `telemetry` plugin was pretty much being a passthrough in many cases to instantiate and maintain the logic of this bit. + +For separation of concerns, it's better to have this piece of logic independent to the rest. diff --git a/src/plugins/telemetry_collection_manager/common/index.ts b/src/plugins/telemetry_collection_manager/common/index.ts new file mode 100644 index 0000000000000..5ad29c06bb682 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/common/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const PLUGIN_ID = 'telemetryCollectionManager'; +export const PLUGIN_NAME = 'telemetry_collection_manager'; diff --git a/src/plugins/telemetry_collection_manager/kibana.json b/src/plugins/telemetry_collection_manager/kibana.json new file mode 100644 index 0000000000000..f4278265834a4 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "telemetryCollectionManager", + "version": "kibana", + "server": true, + "ui": false, + "requiredPlugins": [ + "usageCollection" + ], + "optionalPlugins": [] +} diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.test.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.test.ts rename to src/plugins/telemetry_collection_manager/server/encryption/encrypt.test.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.ts b/src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/encrypt.ts rename to src/plugins/telemetry_collection_manager/server/encryption/encrypt.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/index.ts b/src/plugins/telemetry_collection_manager/server/encryption/index.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/index.ts rename to src/plugins/telemetry_collection_manager/server/encryption/index.ts diff --git a/src/legacy/core_plugins/telemetry/server/collectors/encryption/telemetry_jwks.ts b/src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts similarity index 100% rename from src/legacy/core_plugins/telemetry/server/collectors/encryption/telemetry_jwks.ts rename to src/plugins/telemetry_collection_manager/server/encryption/telemetry_jwks.ts diff --git a/src/plugins/telemetry_collection_manager/server/index.ts b/src/plugins/telemetry_collection_manager/server/index.ts new file mode 100644 index 0000000000000..8761c28e14095 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/index.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 { PluginInitializerContext } from 'kibana/server'; +import { TelemetryCollectionManagerPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new TelemetryCollectionManagerPlugin(initializerContext); +} + +export { + TelemetryCollectionManagerPluginSetup, + TelemetryCollectionManagerPluginStart, + ESLicense, + StatsCollectionConfig, + StatsGetter, + StatsGetterConfig, + StatsCollectionContext, + ClusterDetails, + ClusterDetailsGetter, + LicenseGetter, +} from './types'; diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts new file mode 100644 index 0000000000000..7e8dff9e0aec1 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -0,0 +1,253 @@ +/* + * 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; + +import { + TelemetryCollectionManagerPluginSetup, + TelemetryCollectionManagerPluginStart, + BasicStatsPayload, + CollectionConfig, + Collection, + StatsGetterConfig, + StatsCollectionConfig, + UsageStatsPayload, + StatsCollectionContext, +} from './types'; + +import { encryptTelemetry } from './encryption'; + +interface TelemetryCollectionPluginsDepsSetup { + usageCollection: UsageCollectionSetup; +} + +export class TelemetryCollectionManagerPlugin + implements Plugin { + private readonly logger: Logger; + private readonly collections: Array> = []; + private usageGetterMethodPriority = -1; + private usageCollection?: UsageCollectionSetup; + private readonly isDev: boolean; + private readonly version: string; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + this.isDev = initializerContext.env.mode.dev; + this.version = initializerContext.env.packageInfo.version; + } + + public setup(core: CoreSetup, { usageCollection }: TelemetryCollectionPluginsDepsSetup) { + this.usageCollection = usageCollection; + + return { + setCollection: this.setCollection.bind(this), + getOptInStats: this.getOptInStats.bind(this), + getStats: this.getStats.bind(this), + }; + } + + public start(core: CoreStart) { + return { + setCollection: this.setCollection.bind(this), + getOptInStats: this.getOptInStats.bind(this), + getStats: this.getStats.bind(this), + }; + } + + public stop() {} + + private setCollection, T extends BasicStatsPayload>( + collectionConfig: CollectionConfig + ) { + const { + title, + priority, + esCluster, + statsGetter, + clusterDetailsGetter, + licenseGetter, + } = collectionConfig; + + if (typeof priority !== 'number') { + throw new Error('priority must be set.'); + } + if (priority === this.usageGetterMethodPriority) { + throw new Error(`A Usage Getter with the same priority is already set.`); + } + + if (priority > this.usageGetterMethodPriority) { + if (!statsGetter) { + throw Error('Stats getter method not set.'); + } + if (!esCluster) { + throw Error('esCluster name must be set for the getCluster method.'); + } + if (!clusterDetailsGetter) { + throw Error('Cluster UUIds method is not set.'); + } + if (!licenseGetter) { + throw Error('License getter method not set.'); + } + + this.collections.unshift({ + licenseGetter, + statsGetter, + clusterDetailsGetter, + esCluster, + title, + }); + this.usageGetterMethodPriority = priority; + } + } + + private getStatsCollectionConfig( + config: StatsGetterConfig, + collection: Collection, + usageCollection: UsageCollectionSetup + ): StatsCollectionConfig { + const { start, end, request } = config; + + const callCluster = config.unencrypted + ? collection.esCluster.asScoped(request).callAsCurrentUser + : collection.esCluster.callAsInternalUser; + + return { callCluster, start, end, usageCollection }; + } + + private async getOptInStats(optInStatus: boolean, config: StatsGetterConfig) { + if (!this.usageCollection) { + return []; + } + for (const collection of this.collections) { + const statsCollectionConfig = this.getStatsCollectionConfig( + config, + collection, + this.usageCollection + ); + try { + const optInStats = await this.getOptInStatsForCollection( + collection, + optInStatus, + statsCollectionConfig + ); + if (optInStats && optInStats.length) { + this.logger.debug(`Got Opt In stats using ${collection.title} collection.`); + if (config.unencrypted) { + return optInStats; + } + return encryptTelemetry(optInStats, this.isDev); + } + } catch (err) { + this.logger.debug(`Failed to collect any opt in stats with registered collections.`); + // swallow error to try next collection; + } + } + + return []; + } + + private getOptInStatsForCollection = async ( + collection: Collection, + optInStatus: boolean, + statsCollectionConfig: StatsCollectionConfig + ) => { + const context: StatsCollectionContext = { + logger: this.logger.get(collection.title), + isDev: this.isDev, + version: this.version, + ...collection.customContext, + }; + + const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig, context); + return clustersDetails.map(({ clusterUuid }) => ({ + cluster_uuid: clusterUuid, + opt_in_status: optInStatus, + })); + }; + + private async getStats(config: StatsGetterConfig) { + if (!this.usageCollection) { + return []; + } + for (const collection of this.collections) { + const statsCollectionConfig = this.getStatsCollectionConfig( + config, + collection, + this.usageCollection + ); + try { + const usageData = await this.getUsageForCollection(collection, statsCollectionConfig); + if (usageData.length) { + this.logger.debug(`Got Usage using ${collection.title} collection.`); + if (config.unencrypted) { + return usageData; + } + return encryptTelemetry(usageData, this.isDev); + } + } catch (err) { + this.logger.debug( + `Failed to collect any usage with registered collection ${collection.title}.` + ); + // swallow error to try next collection; + } + } + + return []; + } + + private async getUsageForCollection( + collection: Collection, + statsCollectionConfig: StatsCollectionConfig + ): Promise { + const context: StatsCollectionContext = { + logger: this.logger.get(collection.title), + isDev: this.isDev, + version: this.version, + ...collection.customContext, + }; + + const clustersDetails = await collection.clusterDetailsGetter(statsCollectionConfig, context); + + if (clustersDetails.length === 0) { + // don't bother doing a further lookup, try next collection. + return []; + } + + const [stats, licenses] = await Promise.all([ + collection.statsGetter(clustersDetails, statsCollectionConfig, context), + collection.licenseGetter(clustersDetails, statsCollectionConfig, context), + ]); + + return stats.map(stat => { + const license = licenses[stat.cluster_uuid]; + return { + ...(license ? { license } : {}), + ...stat, + collectionSource: collection.title, + }; + }); + } +} diff --git a/src/plugins/telemetry_collection_manager/server/types.ts b/src/plugins/telemetry_collection_manager/server/types.ts new file mode 100644 index 0000000000000..e23d6a4c388f4 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/types.ts @@ -0,0 +1,150 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { APICaller, Logger, KibanaRequest, IClusterClient } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { TelemetryCollectionManagerPlugin } from './plugin'; + +export interface TelemetryCollectionManagerPluginSetup { + setCollection: , T extends BasicStatsPayload>( + collectionConfig: CollectionConfig + ) => void; + getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; + getStats: TelemetryCollectionManagerPlugin['getStats']; +} + +export interface TelemetryCollectionManagerPluginStart { + setCollection: , T extends BasicStatsPayload>( + collectionConfig: CollectionConfig + ) => void; + getOptInStats: TelemetryCollectionManagerPlugin['getOptInStats']; + getStats: TelemetryCollectionManagerPlugin['getStats']; +} + +export interface TelemetryOptInStats { + cluster_uuid: string; + opt_in_status: boolean; +} + +export interface BaseStatsGetterConfig { + unencrypted: boolean; + start: string; + end: string; + request?: KibanaRequest; +} + +export interface EncryptedStatsGetterConfig extends BaseStatsGetterConfig { + unencrypted: false; +} + +export interface UnencryptedStatsGetterConfig extends BaseStatsGetterConfig { + unencrypted: true; + request: KibanaRequest; +} + +export interface ClusterDetails { + clusterUuid: string; +} + +export interface StatsCollectionConfig { + usageCollection: UsageCollectionSetup; + callCluster: APICaller; + start: string | number; + end: string | number; +} + +export interface BasicStatsPayload { + timestamp: string; + cluster_uuid: string; + cluster_name: string; + version: string; + cluster_stats: object; + collection?: string; + stack_stats: object; +} + +export interface UsageStatsPayload extends BasicStatsPayload { + license?: ESLicense; + collectionSource: string; +} + +// From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html +export interface ESLicense { + status: string; + uid: string; + type: string; + issue_date: string; + issue_date_in_millis: number; + expiry_date: string; + expirty_date_in_millis: number; + max_nodes: number; + issued_to: string; + issuer: string; + start_date_in_millis: number; +} + +export interface StatsCollectionContext { + logger: Logger; + isDev: boolean; + version: string; +} + +export type StatsGetterConfig = UnencryptedStatsGetterConfig | EncryptedStatsGetterConfig; +export type ClusterDetailsGetter = {}> = ( + config: StatsCollectionConfig, + context: StatsCollectionContext & CustomContext +) => Promise; +export type StatsGetter< + CustomContext extends Record = {}, + T extends BasicStatsPayload = BasicStatsPayload +> = ( + clustersDetails: ClusterDetails[], + config: StatsCollectionConfig, + context: StatsCollectionContext & CustomContext +) => Promise; +export type LicenseGetter = {}> = ( + clustersDetails: ClusterDetails[], + config: StatsCollectionConfig, + context: StatsCollectionContext & CustomContext +) => Promise<{ [clusterUuid: string]: ESLicense | undefined }>; + +export interface CollectionConfig< + CustomContext extends Record = {}, + T extends BasicStatsPayload = BasicStatsPayload +> { + title: string; + priority: number; + esCluster: IClusterClient; + statsGetter: StatsGetter; + clusterDetailsGetter: ClusterDetailsGetter; + licenseGetter: LicenseGetter; + customContext?: CustomContext; +} + +export interface Collection< + CustomContext extends Record = {}, + T extends BasicStatsPayload = BasicStatsPayload +> { + customContext?: CustomContext; + statsGetter: StatsGetter; + licenseGetter: LicenseGetter; + clusterDetailsGetter: ClusterDetailsGetter; + esCluster: IClusterClient; + title: string; +} diff --git a/src/plugins/telemetry_management_section/README.md b/src/plugins/telemetry_management_section/README.md new file mode 100644 index 0000000000000..0f795786720c9 --- /dev/null +++ b/src/plugins/telemetry_management_section/README.md @@ -0,0 +1,5 @@ +# Telemetry Management Section + +This plugin adds the Advanced Settings section for the Usage Data collection (aka Telemetry). + +The reason for having it separated from the `telemetry` plugin is to avoid circular dependencies. The plugin `advancedSettings` depends on the `home` app that depends on the `telemetry` plugin because of the telemetry banner in the welcome screen. diff --git a/src/plugins/telemetry_management_section/kibana.json b/src/plugins/telemetry_management_section/kibana.json new file mode 100644 index 0000000000000..3364833def4d6 --- /dev/null +++ b/src/plugins/telemetry_management_section/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "telemetryManagementSection", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": [ + "advancedSettings", + "telemetry" + ] +} diff --git a/src/plugins/telemetry/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap similarity index 100% rename from src/plugins/telemetry/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap rename to src/plugins/telemetry_management_section/public/components/__snapshots__/opt_in_example_flyout.test.tsx.snap diff --git a/src/plugins/telemetry_management_section/public/components/index.ts b/src/plugins/telemetry_management_section/public/components/index.ts new file mode 100644 index 0000000000000..86954744e7a01 --- /dev/null +++ b/src/plugins/telemetry_management_section/public/components/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { OptInExampleFlyout } from './opt_in_example_flyout'; +export { telemetryManagementSectionWrapper } from './telemetry_management_section_wrapper'; +export { TelemetryManagementSection } from './telemetry_management_section'; diff --git a/src/plugins/telemetry/public/components/opt_in_example_flyout.test.tsx b/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.test.tsx similarity index 100% rename from src/plugins/telemetry/public/components/opt_in_example_flyout.test.tsx rename to src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.test.tsx diff --git a/src/plugins/telemetry/public/components/opt_in_example_flyout.tsx b/src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx similarity index 100% rename from src/plugins/telemetry/public/components/opt_in_example_flyout.tsx rename to src/plugins/telemetry_management_section/public/components/opt_in_example_flyout.tsx diff --git a/src/plugins/telemetry/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx similarity index 96% rename from src/plugins/telemetry/public/components/telemetry_management_section.tsx rename to src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx index bf14c33a48048..26e075b666593 100644 --- a/src/plugins/telemetry/public/components/telemetry_management_section.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx @@ -31,11 +31,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { PRIVACY_STATEMENT_URL } from '../../common/constants'; +import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import { PRIVACY_STATEMENT_URL } from '../../../telemetry/common/constants'; import { OptInExampleFlyout } from './opt_in_example_flyout'; import { Field } from '../../../advanced_settings/public'; -import { ToastsStart } from '../../../../core/public/'; -import { TelemetryService } from '../services/telemetry_service'; +import { ToastsStart } from '../../../../core/public'; + +type TelemetryService = TelemetryPluginSetup['telemetryService']; + const SEARCH_TERMS = ['telemetry', 'usage', 'data', 'usage data']; interface Props { diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx new file mode 100644 index 0000000000000..b8b20b68f666e --- /dev/null +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import { TelemetryManagementSection } from './telemetry_management_section'; + +// It should be this but the types are way too vague in the AdvancedSettings plugin `Record` +// type Props = Omit; +type Props = any; + +export function telemetryManagementSectionWrapper( + telemetryService: TelemetryPluginSetup['telemetryService'] +) { + const TelemetryManagementSectionWrapper = (props: Props) => ( + + ); + + return TelemetryManagementSectionWrapper; +} diff --git a/src/plugins/telemetry_management_section/public/index.ts b/src/plugins/telemetry_management_section/public/index.ts new file mode 100644 index 0000000000000..6a80cdd98b1a3 --- /dev/null +++ b/src/plugins/telemetry_management_section/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TelemetryManagementSectionPlugin } from './plugin'; + +export function plugin() { + return new TelemetryManagementSectionPlugin(); +} diff --git a/src/plugins/telemetry_management_section/public/plugin.ts b/src/plugins/telemetry_management_section/public/plugin.ts new file mode 100644 index 0000000000000..738b38c36d30d --- /dev/null +++ b/src/plugins/telemetry_management_section/public/plugin.ts @@ -0,0 +1,54 @@ +/* + * 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 { AdvancedSettingsSetup } from 'src/plugins/advanced_settings/public'; +import { TelemetryPluginSetup } from 'src/plugins/telemetry/public'; +import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; + +import { telemetryManagementSectionWrapper } from './components/telemetry_management_section_wrapper'; + +export interface TelemetryPluginConfig { + enabled: boolean; + url: string; + banner: boolean; + allowChangingOptInStatus: boolean; + optIn: boolean | null; + optInStatusUrl: string; + sendUsageFrom: 'browser' | 'server'; + telemetryNotifyUserAboutOptInDefault?: boolean; +} + +export interface TelemetryManagementSectionPluginDepsSetup { + telemetry: TelemetryPluginSetup; + advancedSettings: AdvancedSettingsSetup; +} + +export class TelemetryManagementSectionPlugin implements Plugin { + public setup( + core: CoreSetup, + { advancedSettings, telemetry: { telemetryService } }: TelemetryManagementSectionPluginDepsSetup + ) { + advancedSettings.component.register( + advancedSettings.component.componentType.PAGE_FOOTER_COMPONENT, + telemetryManagementSectionWrapper(telemetryService), + true + ); + } + + public start(core: CoreStart) {} +} diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 4b81fe9b22083..e32dfae35832b 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -10,37 +10,100 @@ To integrate with the telemetry services for usage collection of your feature, t All you need to provide is a `type` for organizing your fields, and a `fetch` method for returning your usage data. Then you need to make the Telemetry service aware of the collector by registering it. -### New Platform: +### New Platform 1. Make sure `usageCollection` is in your optional Plugins: -```json -// plugin/kibana.json -{ - "id": "...", - "optionalPlugins": ["usageCollection"] -} -``` + ```json + // plugin/kibana.json + { + "id": "...", + "optionalPlugins": ["usageCollection"] + } + ``` 2. Register Usage collector in the `setup` function: + ```ts + // server/plugin.ts + import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + import { CoreSetup, CoreStart } from 'kibana/server'; + + class Plugin { + public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { + registerMyPluginUsageCollector(plugins.usageCollection); + } + + public start(core: CoreStart) {} + } + ``` + +3. Creating and registering a Usage Collector. Ideally collectors would be defined in a separate directory `server/collectors/register.ts`. + + ```ts + // server/collectors/register.ts + import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + import { APICluster } from 'kibana/server'; + + export function registerMyPluginUsageCollector(usageCollection?: UsageCollectionSetup): void { + // usageCollection is an optional dependency, so make sure to return if it is not registered. + if (!usageCollection) { + return; + } + + // create usage collector + const myCollector = usageCollection.makeUsageCollector({ + type: MY_USAGE_TYPE, + fetch: async (callCluster: APICluster) => { + + // query ES and get some data + // summarize the data into a model + // return the modeled object that includes whatever you want to track + + return { + my_objects: { + total: SOME_NUMBER + } + }; + }, + }); + + // register usage collector + usageCollection.registerCollector(myCollector); + } + ``` + +Some background: The `callCluster` that gets passed to the `fetch` method is created in a way that's a bit tricky, to support multiple contexts the `fetch` method could be called. Your `fetch` method could get called as a result of an HTTP API request: in this case, the `callCluster` function wraps `callWithRequest`, and the request headers are expected to have read privilege on the entire `.kibana` index. The use case for this is stats pulled from a Kibana Metricbeat module, where the Beat calls Kibana's stats API in Kibana to invoke collection. + +Note: there will be many cases where you won't need to use the `callCluster` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS, or use other clients like a custom SavedObjects client. In that case it's up to the plugin to initialize those clients like the example below: + ```ts // server/plugin.ts +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { CoreSetup, CoreStart } from 'kibana/server'; + class Plugin { - setup(core, plugins) { - registerMyPluginUsageCollector(plugins.usageCollection); + private savedObjectsRepository?: ISavedObjectsRepository; + + public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { + registerMyPluginUsageCollector(() => this.savedObjectsRepository, plugins.usageCollection); + } + + public start(core: CoreStart) { + this.savedObjectsRepository = core.savedObjects.createInternalRepository(); } } ``` -3. Creating and registering a Usage Collector. Ideally collectors would be defined in a separate directory `server/collectors/register.ts`. - ```ts // server/collectors/register.ts import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { ISavedObjectsRepository } from 'kibana/server'; -export function registerMyPluginUsageCollector(usageCollection?: UsageCollectionSetup): void { +export function registerMyPluginUsageCollector( + getSavedObjectsRepository: () => ISavedObjectsRepository | undefined, + usageCollection?: UsageCollectionSetup + ): void { // usageCollection is an optional dependency, so make sure to return if it is not registered. if (!usageCollection) { return; @@ -49,17 +112,12 @@ export function registerMyPluginUsageCollector(usageCollection?: UsageCollection // create usage collector const myCollector = usageCollection.makeUsageCollector({ type: MY_USAGE_TYPE, - fetch: async (callCluster: CallCluster) => { + isReady: () => typeof getSavedObjectsRepository() !== 'undefined', + fetch: async () => { + const savedObjectsRepository = getSavedObjectsRepository()!; + // get something from the savedObjects - // query ES and get some data - // summarize the data into a model - // return the modeled object that includes whatever you want to track - - return { - my_objects: { - total: SOME_NUMBER - } - }; + return { my_objects }; }, }); @@ -68,11 +126,7 @@ export function registerMyPluginUsageCollector(usageCollection?: UsageCollection } ``` -Some background: The `callCluster` that gets passed to the `fetch` method is created in a way that's a bit tricky, to support multiple contexts the `fetch` method could be called. Your `fetch` method could get called as a result of an HTTP API request: in this case, the `callCluster` function wraps `callWithRequest`, and the request headers are expected to have read privilege on the entire `.kibana` index. The use case for this is stats pulled from a Kibana Metricbeat module, where the Beat calls Kibana's stats API in Kibana to invoke collection. - -Note: there will be many cases where you won't need to use the `callCluster` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS. - -### Migrating to NP from Legacy Plugins: +### Migrating to NP from Legacy Plugins Pass `usageCollection` to the setup NP plugin setup function under plugins. Inside the `setup` function call the `registerCollector` like what you'd do in the NP example above. @@ -91,7 +145,7 @@ export const myPlugin = (kibana: any) => { } ``` -### Legacy Plugins: +### Legacy Plugins Typically, a plugin will create the collector object and register it with the Telemetry service from the `init` method of the plugin definition, or a helper module called from `init`. @@ -109,7 +163,7 @@ export const myPlugin = (kibana: any) => { ## Update the telemetry payload and telemetry cluster field mappings -There is a module in the telemetry service that creates the payload of data that gets sent up to the telemetry cluster. +There is a module in the telemetry service that creates the payload of data that gets sent up to the telemetry cluster. New fields added to the telemetry payload currently mean that telemetry cluster field mappings have to be updated, so they can be searched and aggregated in Kibana visualizations. This is also a short-term obligation. In the next refactoring phase, collectors will need to use a proscribed data model that eliminates maintenance of mappings in the telemetry cluster. @@ -122,12 +176,14 @@ There are a few ways you can test that your usage collector is working properly. - The `.monitoring-*` indices, when Monitoring is enabled. Monitoring enhances the sent payload of telemetry by producing usage data potentially of multiple clusters that exist in the monitoring data. Monitoring data is time-based, and the time frame of collection is the last 15 minutes. - Live-pulled from ES API endpoints. This will get just real-time stats without context of historical data. - The dev script in x-pack can be run on the command-line with: - ``` + + ```shell cd x-pack node scripts/api_debug.js telemetry --host=http://localhost:5601 ``` + Where `http://localhost:5601` is a Kibana server running in dev mode. If needed, authentication and basePath info can be provided in the command as well. - - Automatic inclusion of all the stats fetched by collectors is added in https://github.com/elastic/kibana/pull/22336 / 6.5.0 + - Automatic inclusion of all the stats fetched by collectors is added in [#22336](https://github.com/elastic/kibana/pull/22336) / 6.5.0 3. In Dev mode, Kibana will send telemetry data to a staging telemetry cluster. Assuming you have access to the staging cluster, you can log in and check the latest documents for your new fields. 4. If you catch the network traffic coming from your browser when a telemetry payload is sent, you can examine the request payload body to see the data. This can be tricky as telemetry payloads are sent only once per day per browser. Use incognito mode or clear your localStorage data to force a telemetry payload. @@ -157,7 +213,33 @@ the name of a dashboard they've viewed, or the timestamp of the interaction. ## How to use it -To track a user interaction, import the `createUiStatsReporter` helper function from UI Metric app: +To track a user interaction, use the `reportUiStats` method exposed by the plugin `usageCollection` in the public side: + +1. Similarly to the server-side usage collection, make sure `usageCollection` is in your optional Plugins: + + ```json + // plugin/kibana.json + { + "id": "...", + "optionalPlugins": ["usageCollection"] + } + ``` + +2. Register Usage collector in the `setup` function: + + ```ts + // public/plugin.ts + class Plugin { + setup(core, { usageCollection }) { + if (usageCollection) { + // Call the following method as many times as you want to report an increase in the count for this event + usageCollection.reportUiStats(``, usageCollection.METRIC_TYPE.CLICK, ``); + } + } + } + ``` + +Alternatively, in the Legacy world you can still import the `createUiStatsReporter` helper function from UI Metric app: ```js import { createUiStatsReporter, METRIC_TYPE } from 'relative/path/to/src/legacy/core_plugins/ui_metric/public'; @@ -167,9 +249,10 @@ trackMetric('click', ``); ``` Metric Types: - - `METRIC_TYPE.CLICK` for tracking clicks `trackMetric(METRIC_TYPE.CLICK, 'my_button_clicked');` - - `METRIC_TYPE.LOADED` for a component load or page load `trackMetric(METRIC_TYPE.LOADED', 'my_component_loaded');` - - `METRIC_TYPE.COUNT` for a tracking a misc count `trackMetric(METRIC_TYPE.COUNT', 'my_counter', });` + +- `METRIC_TYPE.CLICK` for tracking clicks `trackMetric(METRIC_TYPE.CLICK, 'my_button_clicked');` +- `METRIC_TYPE.LOADED` for a component load or page load `trackMetric(METRIC_TYPE.LOADED', 'my_component_loaded');` +- `METRIC_TYPE.COUNT` for a tracking a misc count `trackMetric(METRIC_TYPE.COUNT', 'my_counter', });` Call this function whenever you would like to track a user interaction within your app. The function accepts two arguments, `metricType` and `eventNames`. These should be underscore-delimited strings. @@ -196,7 +279,7 @@ use a `eventName` of `create_vis_1m`, `create_vis_5m`, `create_vis_20m`, or `cr ## How it works Under the hood, your app and metric type will be stored in a saved object of type `user-metric` and the -ID `ui-metric:my_app:my_metric`. This saved object will have a `count` property which will be incremented +ID `ui-metric:my_app:my_metric`. This saved object will have a `count` property which will be incremented every time the above URI is hit. These saved objects are automatically consumed by the stats API and surfaced under the @@ -216,4 +299,6 @@ These saved objects are automatically consumed by the stats API and surfaced und ``` By storing these metrics and their counts as key-value pairs, we can add more metrics without having -to worry about exceeding the 1000-field soft limit in Elasticsearch. \ No newline at end of file +to worry about exceeding the 1000-field soft limit in Elasticsearch. + +The only caveat is that it makes it harder to consume in Kibana when analysing each entry in the array separately. In the telemetry team we are working to find a solution to this. We are building a new way of reporting telemetry called [Pulse](../../../rfcs/text/0008_pulse.md) that will help on making these UI-Metrics easier to consume. diff --git a/src/plugins/usage_collection/server/collector/collector.ts b/src/plugins/usage_collection/server/collector/collector.ts index 91951aa2f3edf..b4f86f67e798d 100644 --- a/src/plugins/usage_collection/server/collector/collector.ts +++ b/src/plugins/usage_collection/server/collector/collector.ts @@ -17,15 +17,14 @@ * under the License. */ -import { Logger } from 'kibana/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { Logger, APICaller } from 'kibana/server'; export type CollectorFormatForBulkUpload = (result: T) => { type: string; payload: U }; export interface CollectorOptions { type: string; init?: Function; - fetch: (callCluster: CallCluster) => Promise | T; + fetch: (callCluster: APICaller) => Promise | T; /* * A hook for allowing the fetched data payload to be organized into a typed * data model for internal bulk upload. See defaultFormatterForBulkUpload for diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 6cc5d057b080a..64a48025be248 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -18,8 +18,7 @@ */ import { snakeCase } from 'lodash'; -import { Logger } from 'kibana/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { Logger, APICaller } from 'kibana/server'; import { Collector, CollectorOptions } from './collector'; import { UsageCollector } from './usage_collector'; @@ -31,7 +30,7 @@ interface CollectorSetConfig { export class CollectorSet { private _waitingForAllCollectorsTimestamp?: number; - private logger: Logger; + private readonly logger: Logger; private readonly maximumWaitTimeForAllCollectorsInS: number; private collectors: Array> = []; constructor({ logger, maximumWaitTimeForAllCollectorsInS, collectors = [] }: CollectorSetConfig) { @@ -112,7 +111,7 @@ export class CollectorSet { }; public bulkFetch = async ( - callCluster: CallCluster, + callCluster: APICaller, collectors: Array> = this.collectors ) => { const responses = []; @@ -135,13 +134,13 @@ export class CollectorSet { /* * @return {new CollectorSet} */ - public getFilteredCollectorSet = (filter: any) => { + public getFilteredCollectorSet = (filter: (col: Collector) => boolean) => { const filtered = this.collectors.filter(filter); return this.makeCollectorSetFromArray(filtered); }; - public bulkFetchUsage = async (callCluster: CallCluster) => { - const usageCollectors = this.getFilteredCollectorSet((c: any) => c instanceof UsageCollector); + public bulkFetchUsage = async (callCluster: APICaller) => { + const usageCollectors = this.getFilteredCollectorSet(c => c instanceof UsageCollector); return await this.bulkFetch(callCluster, usageCollectors.collectors); }; diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index 6a28dba50a915..a2769c8b4b405 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../src/core/server'; +import { PluginInitializerContext } from 'kibana/server'; import { UsageCollectionPlugin } from './plugin'; export { UsageCollectionSetup } from './plugin'; diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index 52acb5b3fc86f..00584e1fd5d86 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -18,15 +18,21 @@ */ import { first } from 'rxjs/operators'; -import { CoreStart, ISavedObjectsRepository } from 'kibana/server'; +import { + PluginInitializerContext, + Logger, + CoreSetup, + CoreStart, + ISavedObjectsRepository, + Plugin, +} from 'kibana/server'; import { ConfigType } from './config'; -import { PluginInitializerContext, Logger, CoreSetup } from '../../../../src/core/server'; import { CollectorSet } from './collector'; import { setupRoutes } from './routes'; export type UsageCollectionSetup = CollectorSet; -export class UsageCollectionPlugin { - logger: Logger; +export class UsageCollectionPlugin implements Plugin { + private readonly logger: Logger; private savedObjects?: ISavedObjectsRepository; constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); @@ -39,7 +45,7 @@ export class UsageCollectionPlugin { .toPromise(); const collectorSet = new CollectorSet({ - logger: this.logger, + logger: this.logger.get('collector-set'), maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS, }); diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 5ebb9fdf6330f..3ce8e353e61fc 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -41,9 +41,24 @@ export default function({ getService, getPageObjects }) { }); describe('add new visualization link', () => { - it('adds a new visualization', async () => { + it('adds new visualiztion via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from top nav add new panel' + ); + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new visualization', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await dashboardAddPanel.ensureAddPanelIsShowing(); await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); await PageObjects.visualize.clickAreaChart(); diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js index ded4eca908410..9a4bb0081b7ad 100644 --- a/test/functional/apps/getting_started/_shakespeare.js +++ b/test/functional/apps/getting_started/_shakespeare.js @@ -60,8 +60,7 @@ export default function({ getService, getPageObjects }) { it('should create shakespeare index pattern', async function() { log.debug('Create shakespeare index pattern'); await PageObjects.settings.createIndexPattern('shakes', null); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('shakes*'); }); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 4ef02f6c9e873..35c43c4633410 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -51,8 +51,7 @@ export default function({ getService, getPageObjects }) { it('should be able to create index pattern without time field', async function() { await PageObjects.settings.createIndexPattern('alias1', null); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('alias1*'); }); @@ -66,8 +65,7 @@ export default function({ getService, getPageObjects }) { it('should be able to create index pattern with timefield', async function() { await PageObjects.settings.createIndexPattern('alias2', 'date'); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('alias2*'); }); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index 8aabe42ccc3db..cd39f1cf25ccc 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -19,12 +19,14 @@ import expect from '@kbn/expect'; import path from 'path'; +import { indexBy } from 'lodash'; export default function({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['common', 'settings', 'header']); const testSubjects = getService('testSubjects'); + const log = getService('log'); describe('import objects', function describeIndexTests() { describe('.ndjson file', () => { @@ -33,6 +35,7 @@ export default function({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await esArchiver.load('management'); + await PageObjects.settings.clickKibanaSavedObjects(); }); afterEach(async function() { @@ -40,20 +43,31 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); - const objects = await PageObjects.settings.getSavedObjectsInTable(); - const isSavedObjectImported = objects.includes('Log Agents'); - expect(isSavedObjectImported).to.be(true); + + // get all the elements in the table, and index them by the 'title' visible text field + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + log.debug("check that 'Log Agents' is in table as a visualization"); + expect(elements['Log Agents'].objectType).to.eql('visualization'); + + await elements['logstash-*'].relationshipsElement.click(); + const flyout = indexBy(await PageObjects.settings.getRelationshipFlyout(), 'title'); + log.debug( + "check that 'Shared-Item Visualization AreaChart' shows 'logstash-*' as it's Parent" + ); + expect(flyout['Shared-Item Visualization AreaChart'].relationship).to.eql('Parent'); + log.debug("check that 'Log Agents' shows 'logstash-*' as it's Parent"); + expect(flyout['Log Agents'].relationship).to.eql('Parent'); }); it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_conflicts.ndjson') ); @@ -65,15 +79,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickConfirmChanges(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object with index pattern conflict'); expect(isSavedObjectImported).to.be(true); }); it('should allow the user to override duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can override the existing visualization. await PageObjects.settings.importFile( @@ -93,8 +104,6 @@ export default function({ getService, getPageObjects }) { }); it('should allow the user to cancel overriding duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can be prompted to override the existing visualization. await PageObjects.settings.importFile( @@ -114,21 +123,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects linked to saved searches', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -136,14 +141,11 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson') ); await PageObjects.settings.checkNoneImported(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -151,12 +153,13 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_saved_search.ndjson') ); @@ -164,7 +167,6 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.checkImportConflictsWarning(); await PageObjects.settings.clickConfirmChanges(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -173,14 +175,11 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns already exists', async () => { // First, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -189,19 +188,19 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns does not exists', async () => { // First, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Then, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -215,6 +214,7 @@ export default function({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await esArchiver.load('management'); + await PageObjects.settings.clickKibanaSavedObjects(); }); afterEach(async function() { @@ -222,20 +222,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('Log Agents'); expect(isSavedObjectImported).to.be(true); }); it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects-conflicts.json') ); @@ -248,15 +245,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickConfirmChanges(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object with index pattern conflict'); expect(isSavedObjectImported).to.be(true); }); it('should allow the user to override duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can override the existing visualization. await PageObjects.settings.importFile( @@ -277,8 +271,6 @@ export default function({ getService, getPageObjects }) { }); it('should allow the user to cancel overriding duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can be prompted to override the existing visualization. await PageObjects.settings.importFile( @@ -299,21 +291,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects linked to saved searches', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -321,14 +309,11 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); await PageObjects.settings.checkImportFailedWarning(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -337,7 +322,6 @@ export default function({ getService, getPageObjects }) { it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function() { // First, import the saved search - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.json') ); @@ -346,21 +330,21 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickImportDone(); // Second, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Last, import a saved object connected to the saved search // This should NOT show the conflicts - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); // Wait for all the saves to happen await PageObjects.settings.checkNoneImported(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -369,14 +353,11 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns already exists', async () => { // First, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json') ); await PageObjects.settings.checkImportFailedWarning(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -385,19 +366,19 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns does not exists', async () => { // First, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Then, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index 4661c9b4d53b8..a74620b696d1b 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -71,8 +71,7 @@ export default function({ getService, getPageObjects }) { }); it('should have index pattern in page header', async function() { - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('logstash-*'); }); diff --git a/test/functional/fixtures/es_archiver/management/data.json.gz b/test/functional/fixtures/es_archiver/management/data.json.gz index e4b8f7e954477..cfb08a5ee3a60 100644 Binary files a/test/functional/fixtures/es_archiver/management/data.json.gz and b/test/functional/fixtures/es_archiver/management/data.json.gz differ diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index e0f64340ca7dc..3f6036f58f0a9 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -47,6 +47,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickKibanaSavedObjects() { await testSubjects.click('objects'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } async clickKibanaIndexPatterns() { @@ -168,7 +169,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider } async getIndexPageHeading() { - return await testSubjects.find('indexPatternTitle'); + return await testSubjects.getVisibleText('indexPatternTitle'); } async getConfigureHeader() { @@ -648,6 +649,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickImportDone() { await testSubjects.click('importSavedObjectsDoneBtn'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } async clickConfirmChanges() { @@ -681,9 +683,38 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider }); } + async getSavedObjectElementsInTable() { + const rows = await testSubjects.findAll('~savedObjectsTableRow'); + return mapAsync(rows, async row => { + const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); + // return the object type aria-label="index patterns" + const objectType = await row.findByTestSubject('objectType'); + const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); + // not all rows have inspect button - Advanced Settings objects don't + let inspectElement; + const innerHtml = await row.getAttribute('innerHTML'); + if (innerHtml.includes('Inspect')) { + inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); + } else { + inspectElement = null; + } + const relationshipsElement = await row.findByTestSubject( + 'savedObjectsTableAction-relationships' + ); + return { + checkbox, + objectType: await objectType.getAttribute('aria-label'), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + relationshipsElement, + }; + }); + } + async getSavedObjectsInTable() { const table = await testSubjects.find('savedObjectsTable'); - const cells = await table.findAllByCssSelector('td:nth-child(3)'); + const cells = await table.findAllByTestSubject('savedObjectsTableRowTitle'); const objects = []; for (const cell of cells) { @@ -693,6 +724,23 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return objects; } + async getRelationshipFlyout() { + const rows = await testSubjects.findAll('relationshipsTableRow'); + return mapAsync(rows, async row => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const relationship = await row.findByTestSubject('directRelationship'); + const titleElement = await row.findByTestSubject('relationshipsTitle'); + const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); + return { + objectType: await objectType.getAttribute('aria-label'), + relationship: await relationship.getVisibleText(), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + }; + }); + } + async getSavedObjectsTableSummary() { const table = await testSubjects.find('savedObjectsTable'); const rows = await table.findAllByCssSelector('tbody tr'); @@ -723,17 +771,10 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return await deleteButton.isEnabled(); } - async canSavedObjectBeDeleted(id: string) { - const allCheckBoxes = await testSubjects.findAll('checkboxSelectRow*'); - for (const checkBox of allCheckBoxes) { - if (await checkBox.isSelected()) { - await checkBox.click(); - } - } - - const checkBox = await testSubjects.find(`checkboxSelectRow-${id}`); - await checkBox.click(); - return await this.canSavedObjectsBeDeleted(); + async clickSavedObjectsDelete() { + await testSubjects.click('savedObjectsManagementDelete'); + await testSubjects.click('confirmModalConfirmButton'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } } diff --git a/test/functional/services/dashboard/add_panel.js b/test/functional/services/dashboard/add_panel.js index 91e7c15c4f1d9..6259203982161 100644 --- a/test/functional/services/dashboard/add_panel.js +++ b/test/functional/services/dashboard/add_panel.js @@ -32,6 +32,13 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { await PageObjects.common.sleep(500); } + async clickCreateNewLink() { + log.debug('DashboardAddPanel.clickAddNewPanelButton'); + await testSubjects.click('dashboardAddNewPanelButton'); + // Give some time for the animation to complete + await PageObjects.common.sleep(500); + } + async clickAddNewEmbeddableLink(type) { await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index e63054f1b6912..7017c01cc5634 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -39,6 +39,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/core_plugins'), require.resolve('./test_suites/management'), require.resolve('./test_suites/bfetch_explorer'), + require.resolve('./test_suites/doc_views'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/doc_views_plugin/kibana.json b/test/plugin_functional/plugins/doc_views_plugin/kibana.json new file mode 100644 index 0000000000000..f8596aad01e87 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "docViewPlugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["discover"] +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/package.json b/test/plugin_functional/plugins/doc_views_plugin/package.json new file mode 100644 index 0000000000000..0cef1bf65c0e8 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "docViewPlugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/doc_views_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/src/legacy/core_plugins/kibana_react/public/index.ts b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts similarity index 89% rename from src/legacy/core_plugins/kibana_react/public/index.ts rename to test/plugin_functional/plugins/doc_views_plugin/public/index.ts index a6a7cb72a8dee..8097226180763 100644 --- a/src/legacy/core_plugins/kibana_react/public/index.ts +++ b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts @@ -17,4 +17,6 @@ * under the License. */ -export { Markdown, MarkdownSimple } from '../../../../plugins/kibana_react/public'; +import { DocViewsPlugin } from './plugin'; + +export const plugin = () => new DocViewsPlugin(); diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx new file mode 100644 index 0000000000000..4b9823fda3673 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import angular from 'angular'; +import React from 'react'; +import { Plugin, CoreSetup } from 'kibana/public'; +import { DiscoverSetup } from '../../../../../src/plugins/discover/public'; + +angular.module('myDocView', []).directive('myHit', () => ({ + restrict: 'E', + scope: { + hit: '=hit', + }, + template: '

{{hit._index}}

', +})); + +function MyHit(props: { index: string }) { + return

{props.index}

; +} + +export class DocViewsPlugin implements Plugin { + public setup(core: CoreSetup, { discover }: { discover: DiscoverSetup }) { + discover.docViews.addDocView({ + directive: { + controller: function MyController($injector: any) { + $injector.loadNewModules(['myDocView']); + }, + template: ``, + }, + order: 1, + title: 'Angular doc view', + }); + + discover.docViews.addDocView({ + component: props => { + return ; + }, + order: 2, + title: 'React doc view', + }); + } + + public start() {} +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json new file mode 100644 index 0000000000000..4a564ee1e5578 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*" + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/doc_views/doc_views.ts b/test/plugin_functional/test_suites/doc_views/doc_views.ts new file mode 100644 index 0000000000000..8764f45c2c076 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/doc_views.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 expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + describe('custom doc views', function() { + before(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should show custom doc views', async () => { + await testSubjects.click('docTableExpandToggleColumn'); + const angularTab = await find.byButtonText('Angular doc view'); + const reactTab = await find.byButtonText('React doc view'); + expect(await angularTab.isDisplayed()).to.be(true); + expect(await reactTab.isDisplayed()).to.be(true); + }); + + it('should render angular doc view', async () => { + const angularTab = await find.byButtonText('Angular doc view'); + await angularTab.click(); + const angularContent = await testSubjects.find('angular-docview'); + expect(await angularContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + + it('should render react doc view', async () => { + const reactTab = await find.byButtonText('React doc view'); + await reactTab.click(); + const reactContent = await testSubjects.find('react-docview'); + expect(await reactContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + }); +} diff --git a/test/plugin_functional/test_suites/doc_views/index.ts b/test/plugin_functional/test_suites/doc_views/index.ts new file mode 100644 index 0000000000000..dee3a72e3f2c6 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/index.ts @@ -0,0 +1,31 @@ +/* + * 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 { PluginFunctionalProviderContext } from '../../services'; + +export default function({ getService, loadTestFile }: PluginFunctionalProviderContext) { + const esArchiver = getService('esArchiver'); + + describe('doc views', function() { + before(async () => { + await esArchiver.loadIfNeeded('../functional/fixtures/es_archiver/discover'); + }); + + loadTestFile(require.resolve('./doc_views')); + }); +} diff --git a/test/tsconfig.json b/test/tsconfig.json index 71c9e375a4124..285d3db64a874 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -14,9 +14,10 @@ "**/*.ts", "**/*.tsx", "../typings/lodash.topath/*.ts", + "../typings/elastic__node_crypto.d.ts", "typings/**/*" ], "exclude": [ "plugin_functional/plugins/**/*" ] -} \ No newline at end of file +} diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 2d5d33be49426..784b5a5a42ace 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -31,6 +31,7 @@ "xpack.ml": ["plugins/ml", "legacy/plugins/ml"], "xpack.monitoring": ["plugins/monitoring", "legacy/plugins/monitoring"], "xpack.remoteClusters": "plugins/remote_clusters", + "xpack.painlessLab": "plugins/painless_lab", "xpack.reporting": ["plugins/reporting", "legacy/plugins/reporting"], "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "plugins/searchprofiler", diff --git a/x-pack/legacy/plugins/apm/e2e/.gitignore b/x-pack/legacy/plugins/apm/e2e/.gitignore index 10c769065fc28..a14856506bc6c 100644 --- a/x-pack/legacy/plugins/apm/e2e/.gitignore +++ b/x-pack/legacy/plugins/apm/e2e/.gitignore @@ -1,4 +1,3 @@ -cypress/ingest-data/events.json cypress/screenshots/* - cypress/test-results +tmp diff --git a/x-pack/legacy/plugins/apm/e2e/README.md b/x-pack/legacy/plugins/apm/e2e/README.md index 73a1e860f5564..a891d64539a3f 100644 --- a/x-pack/legacy/plugins/apm/e2e/README.md +++ b/x-pack/legacy/plugins/apm/e2e/README.md @@ -1,58 +1,16 @@ # End-To-End (e2e) Test for APM UI -## Ingest static data into Elasticsearch via APM Server +**Run E2E tests** -1. Start Elasticsearch and APM Server, using [apm-integration-testing](https://github.com/elastic/apm-integration-testing): - -```shell -$ git clone https://github.com/elastic/apm-integration-testing.git -$ cd apm-integration-testing -./scripts/compose.py start master --no-kibana --no-xpack-secure -``` - -2. Download [static data file](https://storage.googleapis.com/apm-ui-e2e-static-data/events.json) - -```shell -$ cd x-pack/legacy/plugins/apm/e2e/cypress/ingest-data -$ curl https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output events.json -``` - -3. Post to APM Server - -```shell -$ cd x-pack/legacy/plugins/apm/e2e/cypress/ingest-data -$ node replay.js --server-url http://localhost:8200 --secret-token abcd --events ./events.json -``` ->This process will take a few minutes to ingest all data - -4. Start Kibana - -```shell -$ yarn kbn bootstrap -$ yarn start --no-base-path --csp.strict=false -``` - -> Content Security Policy (CSP) Settings: Your Kibana instance must have the `csp.strict: false`. - -## How to run the tests - -_Note: Run the following commands from `kibana/x-pack/legacy/plugins/apm/e2e/cypress`._ - -### Interactive mode - -``` -yarn cypress open +```sh +x-pack/legacy/plugins/apm/e2e/run-e2e.sh ``` -### Headless mode - -``` -yarn cypress run -``` +_Starts Kibana, APM Server, Elasticsearch (with sample data) and runs the tests_ ## Reproducing CI builds ->This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. +> This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. The Jenkins CI uses a shell script to prepare Kibana: @@ -60,7 +18,7 @@ The Jenkins CI uses a shell script to prepare Kibana: # Prepare and run Kibana locally $ x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh # Build Docker image for Kibana -$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci +$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci # Run Docker image $ docker run --rm -t --user "$(id -u):$(id -g)" \ -v `pwd`:/app --network="host" \ diff --git a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh b/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh index f7226dca1d276..ae5155d966e58 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh +++ b/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh @@ -7,7 +7,7 @@ if [ -z "${kibana}" ] ; then kibana=127.0.0.1 fi -export CYPRESS_BASE_URL=http://${kibana}:5601 +export CYPRESS_BASE_URL=http://${kibana}:5701 ## To avoid issues with the home and caching artifacts export HOME=/tmp diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml b/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml deleted file mode 100644 index db57db9a1abe9..0000000000000 --- a/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml +++ /dev/null @@ -1,7 +0,0 @@ -## -# Disabled plugins -######################## -logging.verbose: true -elasticsearch.username: "kibana_system_user" -elasticsearch.password: "changeme" -xpack.security.encryptionKey: "something_at_least_32_characters" diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml b/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml new file mode 100644 index 0000000000000..19f3f7c8978fa --- /dev/null +++ b/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml @@ -0,0 +1,31 @@ +# Kibana +server.port: 5701 +xpack.security.encryptionKey: 'something_at_least_32_characters' +csp.strict: false +logging.verbose: true + +# Elasticsearch +# Started via apm-integration-testing +# ./scripts/compose.py start master --no-kibana --elasticsearch-port 9201 --apm-server-port 8201 +elasticsearch.hosts: http://localhost:9201 +elasticsearch.username: 'kibana_system_user' +elasticsearch.password: 'changeme' + +# APM index pattern +apm_oss.indexPattern: apm-* + +# APM Indices +apm_oss.errorIndices: apm-*-error* +apm_oss.sourcemapIndices: apm-*-sourcemap +apm_oss.transactionIndices: apm-*-transaction* +apm_oss.spanIndices: apm-*-span* +apm_oss.metricsIndices: apm-*-metric* +apm_oss.onboardingIndices: apm-*-onboarding* + +# APM options +xpack.apm.enabled: true +xpack.apm.serviceMapEnabled: false +xpack.apm.autocreateApmIndexPattern: true +xpack.apm.ui.enabled: true +xpack.apm.ui.transactionGroupBucketSize: 100 +xpack.apm.ui.maxTraceItems: 1000 diff --git a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh b/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh index 4f176fd0070f5..6df17bd51e0e8 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh +++ b/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh @@ -1,24 +1,21 @@ #!/usr/bin/env bash set -e -CYPRESS_DIR="x-pack/legacy/plugins/apm/e2e" +E2E_DIR="x-pack/legacy/plugins/apm/e2e" echo "1/3 Install dependencies ..." # shellcheck disable=SC1091 source src/dev/ci_setup/setup_env.sh true yarn kbn bootstrap -cp ${CYPRESS_DIR}/ci/kibana.dev.yml config/kibana.dev.yml -echo 'elasticsearch:' >> config/kibana.dev.yml -cp ${CYPRESS_DIR}/ci/kibana.dev.yml config/kibana.yml echo "2/3 Ingest test data ..." -pushd ${CYPRESS_DIR} +pushd ${E2E_DIR} yarn install curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ingest-data/events.json -node ingest-data/replay.js --server-url http://localhost:8200 --secret-token abcd --events ./events.json > ingest-data.log +node ingest-data/replay.js --server-url http://localhost:8201 --secret-token abcd --events ./events.json > ingest-data.log echo "3/3 Start Kibana ..." popd ## Might help to avoid FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory export NODE_OPTIONS="--max-old-space-size=4096" -nohup node scripts/kibana --no-base-path --csp.strict=false --optimize.watch=false> kibana.log 2>&1 & +nohup node scripts/kibana --config "${E2E_DIR}/ci/kibana.e2e.yml" --no-base-path --optimize.watch=false> kibana.log 2>&1 & diff --git a/x-pack/legacy/plugins/apm/e2e/cypress.json b/x-pack/legacy/plugins/apm/e2e/cypress.json index 310964656f107..0894cfd13a197 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress.json +++ b/x-pack/legacy/plugins/apm/e2e/cypress.json @@ -1,5 +1,6 @@ { - "baseUrl": "http://localhost:5601", + "nodeVersion": "system", + "baseUrl": "http://localhost:5701", "video": false, "trashAssetsBeforeRuns": false, "fileServerFolder": "../", @@ -15,5 +16,9 @@ "mochaFile": "./cypress/test-results/[hash]-e2e-tests.xml", "toConsole": false }, - "testFiles": "**/*.{feature,features}" + "testFiles": "**/*.{feature,features}", + "env": { + "elasticsearch_username": "admin", + "elasticsearch_password": "changeme" + } } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature index 01fee2bf68b09..285615108266b 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature @@ -2,6 +2,6 @@ Feature: APM Scenario: Transaction duration charts Given a user browses the APM UI application - When the user inspects the opbeans-go service + When the user inspects the opbeans-node service Then should redirect to correct path with correct params - And should have correct y-axis ticks \ No newline at end of file + And should have correct y-axis ticks diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts b/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts index 1239ef397e086..90d5c9eda632d 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts @@ -6,45 +6,26 @@ /* eslint-disable import/no-extraneous-dependencies */ -import { safeLoad } from 'js-yaml'; - -const RANGE_FROM = '2019-09-04T18:00:00.000Z'; -const RANGE_TO = '2019-09-05T06:00:00.000Z'; +const RANGE_FROM = '2020-03-04T12:30:00.000Z'; +const RANGE_TO = '2020-03-04T13:00:00.000Z'; const BASE_URL = Cypress.config().baseUrl; -/** - * Credentials in the `kibana.dev.yml` config file will be used to authenticate with Kibana - */ -const KIBANA_DEV_YML_PATH = '../../../../../config/kibana.dev.yml'; - /** The default time in ms to wait for a Cypress command to complete */ -export const DEFAULT_TIMEOUT = 30 * 1000; +export const DEFAULT_TIMEOUT = 60 * 1000; export function loginAndWaitForPage(url: string) { - // read the login details from `kibana.dev.yml` - cy.readFile(KIBANA_DEV_YML_PATH).then(kibanaDevYml => { - const config = safeLoad(kibanaDevYml); - const username = config['elasticsearch.username']; - const password = config['elasticsearch.password']; - - const hasCredentials = username && password; - - cy.log( - `Authenticating via config credentials from "${KIBANA_DEV_YML_PATH}". username: ${username}, password: ${password}` - ); + const username = Cypress.env('elasticsearch_username'); + const password = Cypress.env('elasticsearch_password'); - const options = hasCredentials - ? { - auth: { username, password } - } - : {}; + cy.log(`Authenticating via ${username} / ${password}`); - const fullUrl = `${BASE_URL}${url}?rangeFrom=${RANGE_FROM}&rangeTo=${RANGE_TO}`; - cy.visit(fullUrl, options); - }); + const fullUrl = `${BASE_URL}${url}?rangeFrom=${RANGE_FROM}&rangeTo=${RANGE_TO}`; + cy.visit(fullUrl, { auth: { username, password } }); cy.viewport('macbook-15'); // wait for loading spinner to disappear - cy.get('.kibanaLoaderWrap', { timeout: DEFAULT_TIMEOUT }).should('not.exist'); + cy.get('#kbn_loading_message', { timeout: DEFAULT_TIMEOUT }).should( + 'not.exist' + ); } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js index 0e4b91ab45a40..968c2675a62e7 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,19 +1,10 @@ module.exports = { - "When clicking opbeans-go service": { - "transaction duration charts": { - "should have correct y-axis ticks": { - "1": "3.7 min", - "2": "1.8 min", - "3": "0.0 min" - } - } - }, - "__version": "3.8.3", "APM": { "Transaction duration charts": { - "1": "3.7 min", - "2": "1.8 min", - "3": "0.0 min" + "1": "500 ms", + "2": "250 ms", + "3": "0 ms" } - } + }, + "__version": "4.2.0" } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts b/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts index f2f1e515f967a..f58118f3352ea 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts +++ b/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts @@ -12,15 +12,15 @@ Given(`a user browses the APM UI application`, () => { loginAndWaitForPage(`/app/apm#/services`); }); -When(`the user inspects the opbeans-go service`, () => { - // click opbeans-go service - cy.get(':contains(opbeans-go)') +When(`the user inspects the opbeans-node service`, () => { + // click opbeans-node service + cy.get(':contains(opbeans-node)') .last() .click({ force: true }); }); Then(`should redirect to correct path with correct params`, () => { - cy.url().should('contain', `/app/apm#/services/opbeans-go/transactions`); + cy.url().should('contain', `/app/apm#/services/opbeans-node/transactions`); cy.url().should('contain', `transactionType=request`); }); diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js similarity index 60% rename from x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js rename to x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js index 990fc37bb7b2e..59cd34704d624 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js +++ b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js @@ -4,15 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable no-console */ +/* eslint-disable import/no-extraneous-dependencies */ + /** * This script is useful for ingesting previously generated APM data into Elasticsearch via APM Server * * You can either: * 1. Download a static test data file from: https://storage.googleapis.com/apm-ui-e2e-static-data/events.json - * 2. Or, generate the test data file yourself by following the steps in: https://github.com/elastic/kibana/blob/5207a0b68a66d4f513fe1b0cedb021b296641712/x-pack/legacy/plugins/apm/cypress/README.md#generate-static-data + * 2. Or, generate the test data file yourself: + * git clone https://github.com/elastic/apm-integration-testing.git + * ./scripts/compose.py start master --no-kibana --with-opbeans-node --apm-server-record + * docker cp localtesting_8.0.0_apm-server-2:/app/events.json . && cat events.json | wc -l + * + * * * Run the script: * @@ -27,6 +33,7 @@ const axios = require('axios'); const readFile = promisify(fs.readFile); const pLimit = require('p-limit'); const { argv } = require('yargs'); +const ora = require('ora'); const APM_SERVER_URL = argv.serverUrl; const SECRET_TOKEN = argv.secretToken; @@ -43,10 +50,27 @@ if (!EVENTS_PATH) { } const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const requestProgress = { + succeeded: 0, + failed: 0, + total: 0 +}; + +const spinner = ora({ text: 'Warming up...', stream: process.stdout }); + +function updateSpinnerText({ success }) { + success ? requestProgress.succeeded++ : requestProgress.failed++; + const remaining = + requestProgress.total - + (requestProgress.succeeded + requestProgress.failed); + + spinner.text = `Remaining: ${remaining}. Succeeded: ${requestProgress.succeeded}. Failed: ${requestProgress.failed}.`; +} + async function insertItem(item) { try { const url = `${APM_SERVER_URL}${item.url}`; - console.log(Date.now(), url); const headers = { 'content-type': 'application/x-ndjson' @@ -63,20 +87,20 @@ async function insertItem(item) { data: item.body }); + updateSpinnerText({ success: true }); + // add delay to avoid flooding the queue return delay(500); } catch (e) { - console.log('an error occurred'); - if (e.response) { - console.log(e.response.data); - } else { - console.log('error', e); - } + console.error( + `${e.response ? JSON.stringify(e.response.data) : e.message}` + ); + updateSpinnerText({ success: false }); } } async function init() { - const content = await readFile(path.resolve(__dirname, EVENTS_PATH)); + const content = await readFile(path.resolve(EVENTS_PATH)); const items = content .toString() .split('\n') @@ -84,10 +108,21 @@ async function init() { .map(item => JSON.parse(item)) .filter(item => item.url === '/intake/v2/events'); + spinner.start(); + requestProgress.total = items.length; + const limit = pLimit(20); // number of concurrent requests await Promise.all(items.map(item => limit(() => insertItem(item)))); } -init().catch(e => { - console.log('An error occurred:', e); -}); +init() + .catch(e => { + console.log('An error occurred:', e); + process.exit(1); + }) + .then(() => { + spinner.succeed( + `Successfully ingested ${requestProgress.succeeded} of ${requestProgress.total} events` + ); + process.exit(0); + }); diff --git a/x-pack/legacy/plugins/apm/e2e/package.json b/x-pack/legacy/plugins/apm/e2e/package.json index c9026636e64fb..e298be7db514c 100644 --- a/x-pack/legacy/plugins/apm/e2e/package.json +++ b/x-pack/legacy/plugins/apm/e2e/package.json @@ -9,16 +9,18 @@ }, "dependencies": { "@cypress/snapshot": "^2.1.3", - "@cypress/webpack-preprocessor": "^4.1.0", - "@types/cypress-cucumber-preprocessor": "^1.14.0", + "@cypress/webpack-preprocessor": "^4.1.3", + "@types/cypress-cucumber-preprocessor": "^1.14.1", "@types/js-yaml": "^3.12.1", "@types/node": "^10.12.11", - "cypress": "^3.5.0", + "cypress": "^4.2.0", "cypress-cucumber-preprocessor": "^2.0.1", "js-yaml": "^3.13.1", + "ora": "^4.0.3", "p-limit": "^2.2.1", - "ts-loader": "^6.1.0", - "typescript": "3.7.5", - "webpack": "^4.41.5" + "ts-loader": "^6.2.2", + "typescript": "3.8.3", + "wait-on": "^4.0.1", + "webpack": "^4.42.1" } } diff --git a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh new file mode 100755 index 0000000000000..6c9ac83678682 --- /dev/null +++ b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh @@ -0,0 +1,93 @@ +# variables +KIBANA_PORT=5701 +ELASTICSEARCH_PORT=9201 +APM_SERVER_PORT=8201 + +# ensure Docker is running +docker ps &> /dev/null +if [ $? -ne 0 ]; then + echo "⚠️ Please start Docker" + exit 1 +fi + +# formatting +bold=$(tput bold) +normal=$(tput sgr0) + +# Create tmp folder +mkdir -p tmp + +# Ask user to start Kibana +echo " +${bold}To start Kibana please run the following command:${normal} + +node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml +" + +# Clone or pull apm-integration-testing +printf "\n${bold}=== apm-integration-testing ===\n${normal}" + +git clone "https://github.com/elastic/apm-integration-testing.git" "./tmp/apm-integration-testing" &> /dev/null +if [ $? -eq 0 ]; then + echo "Cloning repository" +else + echo "Pulling from master..." + git -C "./tmp/apm-integration-testing" pull &> /dev/null +fi + +# Start apm-integration-testing +echo "Starting (logs: ./tmp/apm-it.log)" +./tmp/apm-integration-testing/scripts/compose.py start master \ + --no-kibana \ + --elasticsearch-port $ELASTICSEARCH_PORT \ + --apm-server-port=$APM_SERVER_PORT \ + --elasticsearch-heap 4g \ + &> ./tmp/apm-it.log + +# Stop if apm-integration-testing failed to start correctly +if [ $? -ne 0 ]; then + printf "⚠️ apm-integration-testing could not be started.\n" + printf "Please see the logs in ./tmp/apm-it.log\n\n" + printf "As a last resort, reset docker with:\n\n./tmp/apm-integration-testing/scripts/compose.py stop && system prune --all --force --volumes\n" + exit 1 +fi + +printf "\n${bold}=== Static mock data ===\n${normal}" + +# Download static data if not already done +if [ -e "./tmp/events.json" ]; then + echo 'Skip: events.json already exists. Not downloading' +else + echo 'Downloading events.json...' + curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ./tmp/events.json +fi + +# echo "Deleting existing indices (apm* and .apm*)" +curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/.apm*" > /dev/null +curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/apm*" > /dev/null + +# Ingest data into APM Server +echo "Ingesting data (logs: tmp/ingest-data.log)" +node ingest-data/replay.js --server-url http://localhost:$APM_SERVER_PORT --events ./tmp/events.json 2> ./tmp/ingest-data.log + +# Install local dependencies +printf "\n" +echo "Installing local dependencies (logs: tmp/e2e-yarn.log)" +yarn &> ./tmp/e2e-yarn.log + +# Wait for Kibana to start +echo "Waiting for Kibana to start..." +yarn wait-on -i 500 -w 500 http://localhost:$KIBANA_PORT > /dev/null + +echo "\n✅ Setup completed successfully. Running tests...\n" + +# run cypress tests +yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true + +echo " + +${bold}If you want to run the test interactively, run:${normal} + +yarn cypress open --config pageLoadTimeout=100000,watchForFileChanges=true +" + diff --git a/x-pack/legacy/plugins/apm/e2e/tsconfig.json b/x-pack/legacy/plugins/apm/e2e/tsconfig.json index de498816e30a4..a7091a20186b2 100644 --- a/x-pack/legacy/plugins/apm/e2e/tsconfig.json +++ b/x-pack/legacy/plugins/apm/e2e/tsconfig.json @@ -1,13 +1,8 @@ { "extends": "../../../../tsconfig.json", - "exclude": [], - "include": [ - "./**/*" - ], + "exclude": ["tmp"], + "include": ["./**/*"], "compilerOptions": { - "types": [ - "cypress", - "node" - ] + "types": ["cypress", "node"] } } diff --git a/x-pack/legacy/plugins/apm/e2e/yarn.lock b/x-pack/legacy/plugins/apm/e2e/yarn.lock index 48e6013fb6986..474337931d665 100644 --- a/x-pack/legacy/plugins/apm/e2e/yarn.lock +++ b/x-pack/legacy/plugins/apm/e2e/yarn.lock @@ -932,10 +932,10 @@ snap-shot-compare "2.8.3" snap-shot-store "1.2.3" -"@cypress/webpack-preprocessor@^4.1.0": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@cypress/webpack-preprocessor/-/webpack-preprocessor-4.1.1.tgz#3c0b5b8de6eaac605dac3b1f1c3f5916c1c6eaea" - integrity sha512-SfzDqOvWBSlfGRm8ak/XHUXAnndwHU2qJIRr1LIC7j2UqWcZoJ+286CuNloJbkwfyEAO6tQggLd4E/WHUAcKZQ== +"@cypress/webpack-preprocessor@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@cypress/webpack-preprocessor/-/webpack-preprocessor-4.1.3.tgz#d5fad767a304c16ec05ca08034827c601f1c9c0c" + integrity sha512-VtTzStrKtwyftLkcgopwCHzgjefK3uHHL6FgbAQP1o5N1pa/zYUb0g7hH2skrMAlKOmLGdbySlISkUl18Y3wHg== dependencies: bluebird "3.7.1" debug "4.1.1" @@ -952,10 +952,62 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@types/cypress-cucumber-preprocessor@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@types/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.0.tgz#41d8ffb2b608d3ed4ab998a0c4394056f75af1e0" - integrity sha512-bOl4u6seZtxNIGa6J6xydroPntTxxWy8uqIrZ3OY10C96fUes4mZvJKY6NvOoe61/OVafG/UEFa+X2ZWKE6Ltw== +"@hapi/address@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.0.0.tgz#36affb4509b5a6adc628bcc394450f2a7d51d111" + integrity sha512-GDDpkCdSUfkQCznmWUHh9dDN85BWf/V8TFKQ2JLuHdGB4Yy3YTEGBzZxoBNxfNBEvreSR/o+ZxBBSNNEVzY+lQ== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@hapi/formula@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" + integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== + +"@hapi/hoek@^9.0.0": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.0.3.tgz#e49e637d5de8faa4f0d313c2590b455d7c00afd7" + integrity sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg== + +"@hapi/joi@^17.1.0": + version "17.1.0" + resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-17.1.0.tgz#cc4000b6c928a6a39b9bef092151b6bdee10ce55" + integrity sha512-ob67RcPlwRWxBzLCnWvcwx5qbwf88I3ykD7gcJLWOTRfLLgosK7r6aeChz4thA3XRvuBfI0KB1tPVl2EQFlPXw== + dependencies: + "@hapi/address" "^4.0.0" + "@hapi/formula" "^2.0.0" + "@hapi/hoek" "^9.0.0" + "@hapi/pinpoint" "^2.0.0" + "@hapi/topo" "^5.0.0" + +"@hapi/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df" + integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== + +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@samverschueren/stream-to-observable@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" + integrity sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg== + dependencies: + any-observable "^0.3.0" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +"@types/cypress-cucumber-preprocessor@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@types/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.1.tgz#9787f4e89553ebc6359ce157a26ad51ed14aa98b" + integrity sha512-CpYsiQ49UrOmadhFg0G5RkokPUmGGctD01mOWjNxFxHw5VgIRv33L2RyFHL8klaAI4HaedGN3Tcj4HTQ65hn+A== "@types/js-yaml@^3.12.1": version "3.12.2" @@ -972,150 +1024,149 @@ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== -"@webassemblyjs/ast@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" - integrity sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ== - dependencies: - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" - -"@webassemblyjs/floating-point-hex-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz#1ba926a2923613edce496fd5b02e8ce8a5f49721" - integrity sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ== - -"@webassemblyjs/helper-api-error@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz#c49dad22f645227c5edb610bdb9697f1aab721f7" - integrity sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA== - -"@webassemblyjs/helper-buffer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz#fea93e429863dd5e4338555f42292385a653f204" - integrity sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q== - -"@webassemblyjs/helper-code-frame@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz#9a740ff48e3faa3022b1dff54423df9aa293c25e" - integrity sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ== - dependencies: - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/helper-fsm@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz#ba0b7d3b3f7e4733da6059c9332275d860702452" - integrity sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow== - -"@webassemblyjs/helper-module-context@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz#def4b9927b0101dc8cbbd8d1edb5b7b9c82eb245" - integrity sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g== - dependencies: - "@webassemblyjs/ast" "1.8.5" - mamacro "^0.0.3" - -"@webassemblyjs/helper-wasm-bytecode@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz#537a750eddf5c1e932f3744206551c91c1b93e61" - integrity sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ== - -"@webassemblyjs/helper-wasm-section@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz#74ca6a6bcbe19e50a3b6b462847e69503e6bfcbf" - integrity sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - -"@webassemblyjs/ieee754@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz#712329dbef240f36bf57bd2f7b8fb9bf4154421e" - integrity sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g== +"@webassemblyjs/ast@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" + integrity sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA== + dependencies: + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + +"@webassemblyjs/floating-point-hex-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4" + integrity sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA== + +"@webassemblyjs/helper-api-error@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2" + integrity sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw== + +"@webassemblyjs/helper-buffer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00" + integrity sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA== + +"@webassemblyjs/helper-code-frame@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27" + integrity sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA== + dependencies: + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/helper-fsm@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8" + integrity sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw== + +"@webassemblyjs/helper-module-context@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07" + integrity sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g== + dependencies: + "@webassemblyjs/ast" "1.9.0" + +"@webassemblyjs/helper-wasm-bytecode@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790" + integrity sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw== + +"@webassemblyjs/helper-wasm-section@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346" + integrity sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + +"@webassemblyjs/ieee754@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4" + integrity sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.8.5.tgz#044edeb34ea679f3e04cd4fd9824d5e35767ae10" - integrity sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A== +"@webassemblyjs/leb128@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95" + integrity sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.8.5.tgz#a8bf3b5d8ffe986c7c1e373ccbdc2a0915f0cedc" - integrity sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw== - -"@webassemblyjs/wasm-edit@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz#962da12aa5acc1c131c81c4232991c82ce56e01a" - integrity sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/helper-wasm-section" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-opt" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/wasm-gen@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz#54840766c2c1002eb64ed1abe720aded714f98bc" - integrity sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wasm-opt@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz#b24d9f6ba50394af1349f510afa8ffcb8a63d264" - integrity sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - -"@webassemblyjs/wasm-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz#21576f0ec88b91427357b8536383668ef7c66b8d" - integrity sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wast-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz#e10eecd542d0e7bd394f6827c49f3df6d4eefb8c" - integrity sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/floating-point-hex-parser" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-code-frame" "1.8.5" - "@webassemblyjs/helper-fsm" "1.8.5" +"@webassemblyjs/utf8@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab" + integrity sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w== + +"@webassemblyjs/wasm-edit@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf" + integrity sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/helper-wasm-section" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-opt" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/wasm-gen@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c" + integrity sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wasm-opt@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61" + integrity sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + +"@webassemblyjs/wasm-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e" + integrity sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wast-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914" + integrity sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/floating-point-hex-parser" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-code-frame" "1.9.0" + "@webassemblyjs/helper-fsm" "1.9.0" "@xtuc/long" "4.2.2" -"@webassemblyjs/wast-printer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz#114bbc481fd10ca0e23b3560fa812748b0bae5bc" - integrity sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg== +"@webassemblyjs/wast-printer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899" + integrity sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA== dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" "@wildpeaks/snapshot-dom@1.6.0": @@ -1195,10 +1246,10 @@ am-i-a-dependency@1.1.2: resolved "https://registry.yarnpkg.com/am-i-a-dependency/-/am-i-a-dependency-1.1.2.tgz#f9d3422304d6f642f821e4c407565035f6167f1f" integrity sha1-+dNCIwTW9kL4IeTEB1ZQNfYWfx8= -ansi-escapes@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= +ansi-escapes@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== ansi-regex@^2.0.0: version "2.1.1" @@ -1215,6 +1266,11 @@ ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + ansi-styles@^2.0.1, ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -1227,6 +1283,19 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +any-observable@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" + integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -1240,7 +1309,7 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -aproba@^1.0.3, aproba@^1.1.1: +aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== @@ -1250,14 +1319,6 @@ arch@2.1.1: resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1338,12 +1399,10 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" - integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== - dependencies: - lodash "^4.17.10" +async@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== asynckit@^0.4.0: version "0.4.0" @@ -1447,11 +1506,6 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bluebird@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" - integrity sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw= - bluebird@3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" @@ -1462,7 +1516,7 @@ bluebird@3.7.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" integrity sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg== -bluebird@^3.4.1, bluebird@^3.5.5: +bluebird@3.7.2, bluebird@^3.4.1, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -1781,12 +1835,10 @@ cached-path-relative@^1.0.0, cached-path-relative@^1.0.2: resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db" integrity sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg== -cachedir@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-1.3.0.tgz#5e01928bf2d95b5edd94b0942188246740e0dbc4" - integrity sha512-O1ji32oyON9laVPJL1IZ5bmwd2cB46VfpxkDequezH+15FDzzVddEyrGEeX4WusDSqKxdyFdDQDEG1yo1GoWkg== - dependencies: - os-homedir "^1.0.1" +cachedir@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" + integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== caniuse-lite@^1.0.30001023: version "1.0.30001027" @@ -1810,7 +1862,7 @@ chai@^4.1.2: pathval "^1.1.0" type-detect "^4.0.5" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1830,6 +1882,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" @@ -1871,10 +1931,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -ci-info@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" - integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" @@ -1901,10 +1961,34 @@ cli-cursor@^1.0.2: dependencies: restore-cursor "^1.0.1" -cli-spinners@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" - integrity sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw= +cli-cursor@^2.0.0, cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.2.0.tgz#e8b988d9206c692302d8ee834e7a85c0144d8f77" + integrity sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ== + +cli-table3@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" + integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== + dependencies: + object-assign "^4.1.0" + string-width "^2.1.1" + optionalDependencies: + colors "^1.1.2" cli-table@^0.3.1: version "0.3.1" @@ -1921,6 +2005,11 @@ cli-truncate@^0.2.1: slice-ansi "0.0.4" string-width "^1.0.1" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -1954,11 +2043,23 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -1986,10 +2087,10 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" - integrity sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag== +commander@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" + integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== commander@^2.19.0, commander@^2.20.0, commander@^2.9.0: version "2.20.3" @@ -2039,11 +2140,6 @@ console-browserify@^1.1.0: resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - constants-browserify@^1.0.0, constants-browserify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -2242,42 +2338,45 @@ cypress-cucumber-preprocessor@^2.0.1: minimist "^1.2.0" through "^2.3.8" -cypress@^3.5.0: - version "3.8.3" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.8.3.tgz#e921f5482f1cbe5814891c878f26e704bbffd8f4" - integrity sha512-I9L/d+ilTPPA4vq3NC1OPKmw7jJIpMKNdyfR8t1EXYzYCjyqbc59migOm1YSse/VRbISLJ+QGb5k4Y3bz2lkYw== +cypress@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-4.2.0.tgz#45673fb648b1a77b9a78d73e58b89ed05212d243" + integrity sha512-8LdreL91S/QiTCLYLNbIjLL8Ht4fJmu/4HGLxUI20Tc7JSfqEfCmXELrRfuPT0kjosJwJJZacdSji9XSRkPKUw== dependencies: "@cypress/listr-verbose-renderer" "0.4.1" "@cypress/xvfb" "1.2.4" "@types/sizzle" "2.3.2" arch "2.1.1" - bluebird "3.5.0" - cachedir "1.3.0" + bluebird "3.7.2" + cachedir "2.3.0" chalk "2.4.2" check-more-types "2.24.0" - commander "2.15.1" + cli-table3 "0.5.1" + commander "4.1.0" common-tags "1.8.0" - debug "3.2.6" + debug "4.1.1" eventemitter2 "4.1.2" - execa "0.10.0" + execa "1.0.0" executable "4.1.1" extract-zip "1.6.7" - fs-extra "5.0.0" - getos "3.1.1" - is-ci "1.2.1" + fs-extra "8.1.0" + getos "3.1.4" + is-ci "2.0.0" is-installed-globally "0.1.0" lazy-ass "1.6.0" - listr "0.12.0" + listr "0.14.3" lodash "4.17.15" - log-symbols "2.2.0" - minimist "1.2.0" + log-symbols "3.0.0" + minimist "1.2.2" moment "2.24.0" - ramda "0.24.1" - request "2.88.0" + ospath "1.2.2" + pretty-bytes "5.3.0" + ramda "0.26.1" + request cypress-io/request#b5af0d1fa47eec97ba980cde90a13e69a2afcd16 request-progress "3.0.0" - supports-color "5.5.0" + supports-color "7.1.0" tmp "0.1.0" - untildify "3.0.3" + untildify "4.0.0" url "0.11.0" yauzl "2.10.0" @@ -2320,13 +2419,6 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@3.2.6, debug@^3.0.1, debug@^3.1.0, debug@^3.2.6: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - debug@4.1.1, debug@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -2334,6 +2426,13 @@ debug@4.1.1, debug@^4.1.0: dependencies: ms "^2.1.1" +debug@^3.0.1, debug@^3.1.0: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -2346,10 +2445,12 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.2" define-properties@^1.1.2: version "1.1.3" @@ -2390,11 +2491,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - deps-sort@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/deps-sort/-/deps-sort-2.0.1.tgz#9dfdc876d2bcec3386b6829ac52162cda9fa208d" @@ -2413,11 +2509,6 @@ des.js@^1.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detective@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" @@ -2651,13 +2742,13 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" -execa@0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" - integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw== +execa@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== dependencies: cross-spawn "^6.0.0" - get-stream "^3.0.0" + get-stream "^4.0.0" is-stream "^1.1.0" npm-run-path "^2.0.0" p-finally "^1.0.0" @@ -2784,7 +2875,7 @@ figgy-pudding@^3.5.1: resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== -figures@2.0.0: +figures@2.0.0, figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= @@ -2889,15 +2980,6 @@ from2@^2.1.0: inherits "^2.0.1" readable-stream "^2.0.0" -fs-extra@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd" - integrity sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ== - dependencies: - graceful-fs "^4.1.2" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" @@ -2907,12 +2989,14 @@ fs-extra@7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" -fs-minipass@^1.2.5: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" - integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== +fs-extra@8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== dependencies: - minipass "^2.6.0" + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" fs-write-stream-atomic@^1.0.8: version "1.0.10" @@ -2942,20 +3026,6 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - gensync@^1.0.0-beta.1: version "1.0.0-beta.1" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" @@ -2971,22 +3041,24 @@ get-func-name@^2.0.0: resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= -getos@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/getos/-/getos-3.1.1.tgz#967a813cceafee0156b0483f7cffa5b3eff029c5" - integrity sha512-oUP1rnEhAr97rkitiszGP9EgDVYnmchgFzfqRzSkgtfv7ai6tEi7Ko8GgjNXts7VLWEqrTWyhsOKLe5C5b/Zkg== +getos@3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/getos/-/getos-3.1.4.tgz#29cdf240ed10a70c049add7b6f8cb08c81876faf" + integrity sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw== dependencies: - async "2.6.1" + async "^3.1.0" getpass@^0.1.1: version "0.1.7" @@ -3032,7 +3104,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== @@ -3042,7 +3114,7 @@ har-schema@^2.0.0: resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= -har-validator@~5.1.0: +har-validator@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== @@ -3062,16 +3134,16 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -3154,13 +3226,6 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - ieee754@^1.1.4: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" @@ -3171,25 +3236,11 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" - integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== - dependencies: - minimatch "^3.0.4" - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA= - dependencies: - repeating "^2.0.0" - indent-string@^3.0.0, indent-string@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" @@ -3223,7 +3274,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@^1.3.4, ini@~1.3.0: +ini@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -3289,12 +3340,12 @@ is-buffer@^1.1.0, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-ci@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" - integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== +is-ci@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== dependencies: - ci-info "^1.5.0" + ci-info "^2.0.0" is-data-descriptor@^0.1.4: version "0.1.4" @@ -3350,11 +3401,6 @@ is-extglob@^2.1.0, is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-finite@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" - integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== - is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -3394,6 +3440,11 @@ is-installed-globally@0.1.0: global-dirs "^0.1.0" is-path-inside "^1.0.0" +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-number@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -3406,6 +3457,13 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-observable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" + integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA== + dependencies: + symbol-observable "^1.1.0" + is-path-inside@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" @@ -3655,10 +3713,10 @@ listr-silent-renderer@^1.1.1: resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4= -listr-update-renderer@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz#ca80e1779b4e70266807e8eed1ad6abe398550f9" - integrity sha1-yoDhd5tOcCZoB+ju0a1qvjmFUPk= +listr-update-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2" + integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA== dependencies: chalk "^1.1.3" cli-truncate "^0.2.1" @@ -3666,40 +3724,33 @@ listr-update-renderer@^0.2.0: figures "^1.7.0" indent-string "^3.0.0" log-symbols "^1.0.2" - log-update "^1.0.2" + log-update "^2.3.0" strip-ansi "^3.0.1" -listr-verbose-renderer@^0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35" - integrity sha1-ggb0z21S3cWCfl/RSYng6WWTOjU= +listr-verbose-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db" + integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw== dependencies: - chalk "^1.1.3" - cli-cursor "^1.0.2" + chalk "^2.4.1" + cli-cursor "^2.1.0" date-fns "^1.27.2" - figures "^1.7.0" + figures "^2.0.0" -listr@0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/listr/-/listr-0.12.0.tgz#6bce2c0f5603fa49580ea17cd6a00cc0e5fa451a" - integrity sha1-a84sD1YD+klYDqF81qAMwOX6RRo= +listr@0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" + integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== dependencies: - chalk "^1.1.3" - cli-truncate "^0.2.1" - figures "^1.7.0" - indent-string "^2.1.0" + "@samverschueren/stream-to-observable" "^0.3.0" + is-observable "^1.1.0" is-promise "^2.1.0" is-stream "^1.1.0" listr-silent-renderer "^1.1.1" - listr-update-renderer "^0.2.0" - listr-verbose-renderer "^0.4.0" - log-symbols "^1.0.2" - log-update "^1.0.2" - ora "^0.2.3" - p-map "^1.1.1" - rxjs "^5.0.0-beta.11" - stream-to-observable "^0.1.0" - strip-ansi "^3.0.1" + listr-update-renderer "^0.5.0" + listr-verbose-renderer "^0.5.0" + p-map "^2.0.0" + rxjs "^6.3.3" loader-runner@^2.4.0: version "2.4.0" @@ -3738,17 +3789,17 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@4.17.15, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.4: +lodash@4.17.15, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.4: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -log-symbols@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" - integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== +log-symbols@3.0.0, log-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== dependencies: - chalk "^2.0.1" + chalk "^2.4.2" log-symbols@^1.0.2: version "1.0.2" @@ -3757,13 +3808,14 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" -log-update@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" - integrity sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE= +log-update@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" + integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg= dependencies: - ansi-escapes "^1.0.0" - cli-cursor "^1.0.2" + ansi-escapes "^3.0.0" + cli-cursor "^2.0.0" + wrap-ansi "^3.0.1" loose-envify@^1.0.0: version "1.4.0" @@ -3800,11 +3852,6 @@ make-dir@^2.0.0: pify "^4.0.1" semver "^5.6.0" -mamacro@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/mamacro/-/mamacro-0.0.3.tgz#ad2c9576197c9f1abf308d0787865bd975a3f3e4" - integrity sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA== - map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" @@ -3889,6 +3936,16 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.43.0" +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -3911,25 +3968,20 @@ minimist@0.0.8: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: +minimist@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#b00a00230a1108c48c169e69a291aafda3aacd63" + integrity sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA== + +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.2.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" - integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== - dependencies: - minipass "^2.9.0" +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== mississippi@^3.0.0: version "3.0.0" @@ -3962,6 +4014,13 @@ mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "0.0.8" +mkdirp@^0.5.3: + version "0.5.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.4.tgz#fd01504a6797ec5c9be81ff43d204961ed64a512" + integrity sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw== + dependencies: + minimist "^1.2.5" + module-deps@^6.0.0: version "6.2.2" resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-6.2.2.tgz#d8a15c2265dfc119153c29bb47386987d0ee423b" @@ -4010,6 +4069,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + mz@^2.4.0: version "2.7.0" resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -4041,15 +4105,6 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -needle@^2.2.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.2.tgz#3342dea100b7160960a450dc8c22160ac712a528" - integrity sha512-DUzITvPVDUy6vczKKYTnWc/pBZ0EnjMJnQ3y+Jo5zfKFimJs7S3HFCxCRZYB9FUZcrzUQr3WsmvZgddMEIZv6w== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - neo-async@^2.5.0, neo-async@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" @@ -4101,22 +4156,6 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - node-releases@^1.1.47: version "1.1.48" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.48.tgz#7f647f0c453a0495bcd64cbd4778c26035c2f03a" @@ -4124,7 +4163,7 @@ node-releases@^1.1.47: dependencies: semver "^6.3.0" -nopt@^4.0.1, nopt@~4.0.1: +nopt@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= @@ -4144,27 +4183,6 @@ normalize-path@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -npm-bundled@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== - dependencies: - npm-normalize-package-bin "^1.0.1" - -npm-normalize-package-bin@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - -npm-packlist@^1.1.6: - version "1.4.8" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" - integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-normalize-package-bin "^1.0.1" - npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -4172,16 +4190,6 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -4247,22 +4255,40 @@ onetime@^1.0.0: resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= -ora@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" - integrity sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q= +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= dependencies: - chalk "^1.1.1" - cli-cursor "^1.0.2" - cli-spinners "^0.1.2" - object-assign "^4.0.1" + mimic-fn "^1.0.0" + +onetime@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" + integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== + dependencies: + mimic-fn "^2.1.0" + +ora@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/ora/-/ora-4.0.3.tgz#752a1b7b4be4825546a7a3d59256fa523b6b6d05" + integrity sha512-fnDebVFyz309A73cqCipVL1fBZewq4vwgSHfxh43vVy31mbyoQ8sCH3Oeaog/owYOs/lLlGVPCISQonTneg6Pg== + dependencies: + chalk "^3.0.0" + cli-cursor "^3.1.0" + cli-spinners "^2.2.0" + is-interactive "^1.0.0" + log-symbols "^3.0.0" + mute-stream "0.0.8" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" os-browserify@^0.3.0, os-browserify@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= -os-homedir@^1.0.0, os-homedir@^1.0.1: +os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= @@ -4280,6 +4306,11 @@ osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" +ospath@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" + integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= + outpipe@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/outpipe/-/outpipe-1.1.1.tgz#50cf8616365e87e031e29a5ec9339a3da4725fa2" @@ -4306,10 +4337,10 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" -p-map@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" - integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== p-try@^2.0.0: version "2.2.0" @@ -4462,6 +4493,11 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +pretty-bytes@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" + integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== + private@^0.1.6: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -4502,7 +4538,7 @@ pseudomap@^1.0.2: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.24: +psl@^1.1.28: version "1.7.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c" integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ== @@ -4549,12 +4585,12 @@ punycode@1.3.2: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4, punycode@^1.3.2, punycode@^1.4.1: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -4574,16 +4610,16 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -ramda@0.24.1: - version "0.24.1" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857" - integrity sha1-w7d1UZfzW43DUCIoJixMkd22uFc= - ramda@0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== +ramda@0.26.1: + version "0.26.1" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -4599,16 +4635,6 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - read-only-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0" @@ -4616,7 +4642,7 @@ read-only-stream@^2.0.0: dependencies: readable-stream "^2.0.2" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -4723,13 +4749,6 @@ repeat-string@^1.5.2, repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - request-progress@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" @@ -4737,10 +4756,51 @@ request-progress@3.0.0: dependencies: throttleit "^1.0.0" -request@2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== +request-promise-core@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9" + integrity sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ== + dependencies: + lodash "^4.17.15" + +request-promise-native@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.8.tgz#a455b960b826e44e2bf8999af64dff2bfe58cb36" + integrity sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ== + dependencies: + request-promise-core "1.1.3" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.88.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +request@cypress-io/request#b5af0d1fa47eec97ba980cde90a13e69a2afcd16: + version "2.88.1" + resolved "https://codeload.github.com/cypress-io/request/tar.gz/b5af0d1fa47eec97ba980cde90a13e69a2afcd16" dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -4749,7 +4809,7 @@ request@2.88.0: extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.0" + har-validator "~5.1.3" http-signature "~1.2.0" is-typedarray "~1.0.0" isstream "~0.1.2" @@ -4759,7 +4819,7 @@ request@2.88.0: performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" - tough-cookie "~2.4.3" + tough-cookie "~2.5.0" tunnel-agent "^0.6.0" uuid "^3.3.2" @@ -4793,12 +4853,28 @@ restore-cursor@^1.0.1: exit-hook "^1.0.0" onetime "^1.0.0" +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3: +rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -4820,12 +4896,12 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^5.0.0-beta.11: - version "5.5.12" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.12.tgz#6fa61b8a77c3d793dbaf270bee2f43f652d741cc" - integrity sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw== +rxjs@^6.3.3, rxjs@^6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" + integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== dependencies: - symbol-observable "1.0.1" + tslib "^1.9.0" safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.0" @@ -4844,16 +4920,11 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - schema-utils@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" @@ -4873,7 +4944,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: +semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -4893,11 +4964,6 @@ serialize-javascript@^2.1.2: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== -set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -4958,7 +5024,7 @@ sigmund@^1.0.1: resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= -signal-exit@^3.0.0: +signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= @@ -5147,6 +5213,11 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + stream-browserify@^2.0.0, stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -5205,11 +5276,6 @@ stream-splicer@^2.0.0: inherits "^2.0.1" readable-stream "^2.0.2" -stream-to-observable@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.1.0.tgz#45bf1d9f2d7dc09bed81f1c307c430e68b84cffe" - integrity sha1-Rb8dny19wJvtgfHDB8Qw5ouEz/4= - string-argv@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.0.2.tgz#dac30408690c21f3c3630a3ff3a05877bdcbd736" @@ -5224,7 +5290,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2": +string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -5267,16 +5333,18 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -5284,22 +5352,29 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -supports-color@5.5.0, supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== +supports-color@7.1.0, supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== dependencies: - has-flag "^3.0.0" + has-flag "^4.0.0" supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -symbol-observable@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" - integrity sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ= +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +symbol-observable@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== syntax-error@^1.1.1: version "1.4.0" @@ -5313,19 +5388,6 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tar@^4.4.2: - version "4.4.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" - integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - terser-webpack-plugin@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" @@ -5453,18 +5515,18 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== +tough-cookie@^2.3.3, tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== dependencies: - psl "^1.1.24" - punycode "^1.4.1" + psl "^1.1.28" + punycode "^2.1.1" -ts-loader@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.1.tgz#67939d5772e8a8c6bdaf6277ca023a4812da02ef" - integrity sha512-Dd9FekWuABGgjE1g0TlQJ+4dFUfYGbYcs52/HQObE0ZmUNjQlmLAS7xXsSzy23AMaMwipsx5sNHvoEpT2CZq1g== +ts-loader@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.2.tgz#dffa3879b01a1a1e0a4b85e2b8421dc0dfff1c58" + integrity sha512-HDo5kXZCBml3EUPcc7RlZOV/JGlLHwppTLEHb3SHnr5V7NXD4klMEkrhJe5wgRbaWsSXi+Y1SIBN/K9B6zWGWQ== dependencies: chalk "^2.3.0" enhanced-resolve "^4.0.0" @@ -5519,10 +5581,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.7.5: - version "3.7.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" - integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== +typescript@3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== umd@^3.0.0: version "3.0.3" @@ -5600,10 +5662,10 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -untildify@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9" - integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA== +untildify@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" + integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== upath@^1.1.1: version "1.2.0" @@ -5698,6 +5760,18 @@ vm-browserify@^1.0.0, vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +wait-on@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-4.0.1.tgz#c49ca18b1ea60580404feed9df76ab3af2425a56" + integrity sha512-x83fmTH2X0KL7vXoGt9aV5x4SMCvO8A/NbwWpaYYh4NJ16d3KSgbHwBy9dVdHj0B30cEhOFRvDob4fnpUmZxvA== + dependencies: + "@hapi/joi" "^17.1.0" + lodash "^4.17.15" + minimist "^1.2.0" + request "^2.88.0" + request-promise-native "^1.0.8" + rxjs "^6.5.4" + watchify@3.11.1: version "3.11.1" resolved "https://registry.yarnpkg.com/watchify/-/watchify-3.11.1.tgz#8e4665871fff1ef64c0430d1a2c9d084d9721881" @@ -5720,6 +5794,13 @@ watchpack@^1.6.0: graceful-fs "^4.1.2" neo-async "^2.5.0" +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + webpack-sources@^1.4.0, webpack-sources@^1.4.1: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" @@ -5728,15 +5809,15 @@ webpack-sources@^1.4.0, webpack-sources@^1.4.1: source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.40.2: - version "4.41.5" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.5.tgz#3210f1886bce5310e62bb97204d18c263341b77c" - integrity sha512-wp0Co4vpyumnp3KlkmpM5LWuzvZYayDwM2n17EHFr4qxBBbRokC7DJawPJC7TfSFZ9HZ6GsdH40EBj4UV0nmpw== +webpack@^4.42.1: + version "4.42.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.42.1.tgz#ae707baf091f5ca3ef9c38b884287cfe8f1983ef" + integrity sha512-SGfYMigqEfdGchGhFFJ9KyRpQKnipvEvjc1TwrXEPCM6H5Wywu10ka8o3KGrMzSMxMQKt8aCHUFh5DaQ9UmyRg== 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" + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/wasm-edit" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" acorn "^6.2.1" ajv "^6.10.2" ajv-keywords "^3.4.1" @@ -5748,7 +5829,7 @@ webpack@^4.40.2: loader-utils "^1.2.3" memory-fs "^0.4.1" micromatch "^3.1.10" - mkdirp "^0.5.1" + mkdirp "^0.5.3" neo-async "^2.6.1" node-libs-browser "^2.2.1" schema-utils "^1.0.0" @@ -5764,13 +5845,6 @@ which@^1.2.9: dependencies: isexe "^2.0.0" -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - worker-farm@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" @@ -5778,6 +5852,14 @@ worker-farm@^1.7.0: dependencies: errno "~0.1.7" +wrap-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" + integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo= + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -5798,7 +5880,7 @@ yallist@^2.1.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= -yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: +yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..d1f7ce325d23e 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,7 +14,13 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], + require: [ + 'kibana', + 'elasticsearch', + 'xpack_main', + 'apm_oss', + 'task_manager' + ], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -71,7 +77,15 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + serviceMapFingerprintBucketSize: Joi.number().default(100), + serviceMapTraceIdBucketSize: Joi.number().default(65), + serviceMapFingerprintGlobalBucketSize: Joi.number().default(1000), + serviceMapTraceIdGlobalBucketSize: Joi.number().default(6), + serviceMapMaxTracesPerRequest: Joi.number().default(50), + + // telemetry + telemetryCollectionEnabled: Joi.boolean().default(true) }).default(); }, @@ -82,35 +96,50 @@ export const apm: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { defaultMessage: 'APM' }), + order: 900, icon: 'apmApp', navLinkId: 'apm', app: ['apm', 'kibana'], catalogue: ['apm'], + // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { - api: ['apm', 'apm_write'], + app: ['apm', 'kibana'], + api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show', 'save'] + ui: [ + 'show', + 'save', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete' + ] }, read: { - api: ['apm'], + app: ['apm', 'kibana'], + api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show'] + ui: ['show', 'alerting:show', 'actions:show'] } } }); - const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - apmPlugin.registerLegacyAPI({ server }); + + apmPlugin.registerLegacyAPI({ + server + }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index 61bc90da28756..ba4c7a89ceaa8 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,20 +1,659 @@ { - "apm-services-telemetry": { + "apm-telemetry": { "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "type": "object" + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "type": "object" + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "type": "object" + }, + "runtime": { + "type": "object" + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, "has_any_services": { "type": "boolean" }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "services_per_agent": { "properties": { - "python": { + "dotnet": { "type": "long", "null_value": 0 }, - "java": { + "go": { "type": "long", "null_value": 0 }, - "nodejs": { + "java": { "type": "long", "null_value": 0 }, @@ -22,11 +661,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "nodejs": { "type": "long", "null_value": 0 }, - "dotnet": { + "python": { "type": "long", "null_value": 0 }, @@ -34,11 +673,131 @@ "type": "long", "null_value": 0 }, - "go": { + "rum-js": { "type": "long", "null_value": 0 } } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } } } }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx index c2c396d5b8951..68acaee4abe5d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx @@ -11,9 +11,9 @@ import { APMIndicesPermission } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import { expectTextsInDocument, - MockApmPluginContextWrapper, expectTextsNotInDocument } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('APMIndicesPermission', () => { it('returns empty component when api status is loading', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx index 68d19a41f33a4..a09482d663f65 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx @@ -9,25 +9,7 @@ import React from 'react'; import { mockMoment, toJson } from '../../../../../utils/testHelpers'; import { ErrorGroupList } from '../index'; import props from './props.json'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { - useUiFilters, - UrlParamsContext -} from '../../../../../context/UrlParamsContext'; - -const mockRefreshTimeRange = jest.fn(); -const MockUrlParamsProvider: React.FC<{ - params?: IUrlParams; -}> = ({ params = props.urlParams, children }) => ( - -); +import { MockUrlParamsContextProvider } from '../../../../../context/UrlParamsContext/MockUrlParamsContextProvider'; describe('ErrorGroupOverview -> List', () => { beforeAll(() => { @@ -37,9 +19,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render empty state', () => { const storeState = {}; const wrapper = mount( - + - , + , storeState ); @@ -48,9 +30,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render with data', () => { const wrapper = mount( - + - + ); expect(toJson(wrapper)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json index 92198220628d1..431a6c71b103b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json @@ -1,11 +1,4 @@ { - "urlParams": { - "page": 0, - "serviceName": "opbeans-python", - "transactionType": "request", - "start": "2018-01-10T09:51:41.050Z", - "end": "2018-01-10T10:06:41.050Z" - }, "items": [ { "message": "About to blow up!", diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx index 711290942cea1..ab4ca1dfbb49d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('Home component', () => { it('should render services', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 5bf8cb8271fa4..e610f3b84899b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -8,12 +8,12 @@ import { mount } from 'enzyme'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; -import { - mockApmPluginContextValue, - MockApmPluginContextWrapper -} from '../../../utils/testHelpers'; import { routes } from './route_config'; import { UpdateBreadcrumbs } from './UpdateBreadcrumbs'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; const setBreadcrumbs = jest.fn(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx index 2e737382c67a5..c87e56fe9eff6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -23,6 +23,10 @@ import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUr import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; import { TraceLink } from '../../TraceLink'; import { CustomizeUI } from '../../Settings/CustomizeUI'; +import { + EditAgentConfigurationRouteHandler, + CreateAgentConfigurationRouteHandler +} from './route_handlers/agent_configuration'; const metricsBreadcrumb = i18n.translate('xpack.apm.breadcrumb.metricsTitle', { defaultMessage: 'Metrics' @@ -101,12 +105,31 @@ export const routes: BreadcrumbRoute[] = [ ), breadcrumb: i18n.translate( 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { - defaultMessage: 'Agent Configuration' - } + { defaultMessage: 'Agent Configuration' } ), name: RouteName.AGENT_CONFIGURATION }, + + { + exact: true, + path: '/settings/agent-configuration/create', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', + { defaultMessage: 'Create Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_CREATE, + component: () => + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', + { defaultMessage: 'Edit Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_EDIT, + component: () => + }, { exact: true, path: '/services/:serviceName', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx new file mode 100644 index 0000000000000..58087f0d8be42 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useFetcher } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { Settings } from '../../../Settings'; +import { AgentConfigurationCreateEdit } from '../../../Settings/AgentConfigurations/AgentConfigurationCreateEdit'; +import { toQuery } from '../../../../shared/Links/url_helpers'; + +export function EditAgentConfigurationRouteHandler() { + const { search } = history.location; + + // typescript complains because `pageStop` does not exist in `APMQueryParams` + // Going forward we should move away from globally declared query params and this is a first step + // @ts-ignore + const { name, environment, pageStep } = toQuery(search); + + const res = useFetcher( + callApmApi => { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/view', + params: { query: { name, environment } } + }); + }, + [name, environment] + ); + + return ( + + + + ); +} + +export function CreateAgentConfigurationRouteHandler() { + const { search } = history.location; + + // Ignoring here because we specifically DO NOT want to add the query params to the global route handler + // @ts-ignore + const { pageStep } = toQuery(search); + + return ( + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx index db57e8356f39b..33a4990cb549e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx @@ -20,6 +20,8 @@ export enum RouteName { TRANSACTION_NAME = 'transaction_name', SETTINGS = 'settings', AGENT_CONFIGURATION = 'agent_configuration', + AGENT_CONFIGURATION_CREATE = 'agent_configuration_create', + AGENT_CONFIGURATION_EDIT = 'agent_configuration_edit', INDICES = 'indices', SERVICE_NODES = 'nodes', LINK_TO_TRACE = 'link_to_trace', diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx new file mode 100644 index 0000000000000..7e8d057a7be6c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { AlertType } from '../../../../../../../../../plugins/apm/common/alert_types'; +import { AlertAdd } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + +type AlertAddProps = React.ComponentProps; + +interface Props { + addFlyoutVisible: AlertAddProps['addFlyoutVisible']; + setAddFlyoutVisibility: AlertAddProps['setAddFlyoutVisibility']; + alertType: AlertType | null; +} + +export function AlertingFlyout(props: Props) { + const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; + + return alertType ? ( + + ) : null; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx new file mode 100644 index 0000000000000..92b325ab00d35 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -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 { + EuiButtonEmpty, + EuiContextMenu, + EuiPopover, + EuiContextMenuPanelDescriptor +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { AlertType } from '../../../../../../../../plugins/apm/common/alert_types'; +import { AlertingFlyout } from './AlertingFlyout'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; + +const alertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.alerts', + { + defaultMessage: 'Alerts' + } +); + +const createThresholdAlertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', + { + defaultMessage: 'Create threshold alert' + } +); + +const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; + +interface Props { + canReadAlerts: boolean; + canSaveAlerts: boolean; +} + +export function AlertIntegrations(props: Props) { + const { canSaveAlerts, canReadAlerts } = props; + + const plugin = useApmPluginContext(); + + const [popoverOpen, setPopoverOpen] = useState(false); + + const [alertType, setAlertType] = useState(null); + + const button = ( + setPopoverOpen(true)} + > + {i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', { + defaultMessage: 'Alerts' + })} + + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: alertLabel, + items: [ + ...(canSaveAlerts + ? [ + { + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_ALERT_PANEL_ID, + icon: 'bell' + } + ] + : []), + ...(canReadAlerts + ? [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts', + { + defaultMessage: 'View active alerts' + } + ), + href: plugin.core.http.basePath.prepend( + '/app/kibana#/management/kibana/triggersActions/alerts' + ), + icon: 'tableOfContents' + } + ] + : []) + ] + }, + { + id: CREATE_THRESHOLD_ALERT_PANEL_ID, + title: createThresholdAlertLabel, + items: [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', + { + defaultMessage: 'Transaction duration' + } + ), + onClick: () => { + setAlertType(AlertType.TransactionDuration); + } + }, + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorRate', + { + defaultMessage: 'Error rate' + } + ), + onClick: () => { + setAlertType(AlertType.ErrorRate); + } + } + ] + } + ]; + + return ( + <> + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + { + if (!visible) { + setAlertType(null); + } + }} + /> + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx index ac7dfd49d4f3d..77ae67b71e1b6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -10,15 +10,27 @@ import { ApmHeader } from '../../shared/ApmHeader'; import { ServiceDetailTabs } from './ServiceDetailTabs'; import { ServiceIntegrations } from './ServiceIntegrations'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { AlertIntegrations } from './AlertIntegrations'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; interface Props { tab: React.ComponentProps['tab']; } export function ServiceDetails({ tab }: Props) { + const plugin = useApmPluginContext(); const { urlParams } = useUrlParams(); const { serviceName } = urlParams; + const canReadAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:show' + ]; + const canSaveAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:save' + ]; + + const isAlertingAvailable = canReadAlerts || canSaveAlerts; + return (
@@ -31,6 +43,14 @@ export function ServiceDetails({ tab }: Props) { + {isAlertingAvailable && ( + + + + )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 155695f7596dd..46754c8c7cb6b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -77,6 +77,14 @@ storiesOf('app/ServiceMap/Cytoscape', module) { data: { id: 'default' } }, { data: { id: 'cache', label: 'cache', 'span.type': 'cache' } }, { data: { id: 'database', label: 'database', 'span.type': 'db' } }, + { + data: { + id: 'elasticsearch', + label: 'elasticsearch', + 'span.type': 'db', + 'span.subtype': 'elasticsearch' + } + }, { data: { id: 'external', label: 'external', 'span.type': 'external' } }, @@ -186,6 +194,7 @@ storiesOf('app/ServiceMap/Cytoscape', module) const height = 640; const width = 1340; const serviceName = undefined; // global service map + return ( ( undefined @@ -109,23 +109,28 @@ export function Cytoscape({ serviceName, style }: CytoscapeProps) { - const initialElements = elements.map(element => ({ - ...element, - // prevents flash of unstyled elements - classes: [element.classes, 'invisible'].join(' ').trim() - })); - const [ref, cy] = useCytoscape({ ...cytoscapeOptions, - elements: initialElements + elements }); // Add the height to the div style. The height is a separate prop because it // is required and can trigger rendering when changed. const divStyle = { ...style, height }; - const resetConnectedEdgeStyle = useCallback( - (node?: cytoscape.NodeSingular) => { + const trackApmEvent = useUiTracker({ app: 'apm' }); + + // Trigger a custom "data" event when data changes + useEffect(() => { + if (cy && elements.length > 0) { + cy.add(elements); + cy.trigger('data'); + } + }, [cy, elements]); + + // Set up cytoscape event handlers + useEffect(() => { + const resetConnectedEdgeStyle = (node?: cytoscape.NodeSingular) => { if (cy) { cy.edges().removeClass('highlight'); @@ -133,12 +138,9 @@ export function Cytoscape({ node.connectedEdges().addClass('highlight'); } } - }, - [cy] - ); + }; - const dataHandler = useCallback( - event => { + const dataHandler: cytoscape.EventHandler = event => { if (cy) { if (serviceName) { resetConnectedEdgeStyle(cy.getElementById(serviceName)); @@ -150,37 +152,27 @@ export function Cytoscape({ } else { resetConnectedEdgeStyle(); } - if (event.cy.elements().length > 0) { - const selectedRoots = selectRoots(event.cy); - const layout = cy.layout( - getLayoutOptions(selectedRoots, height, width) - ); - layout.one('layoutstop', () => { - if (serviceName) { - const focusedNode = cy.getElementById(serviceName); - cy.center(focusedNode); - } - // show elements after layout is applied - cy.elements().removeClass('invisible'); - }); - layout.run(); - } - } - }, - [cy, resetConnectedEdgeStyle, serviceName, height, width] - ); - // Trigger a custom "data" event when data changes - useEffect(() => { - if (cy) { - cy.add(elements); - cy.trigger('data'); - } - }, [cy, elements]); + const selectedRoots = selectRoots(event.cy); + const layout = cy.layout( + getLayoutOptions(selectedRoots, height, width) + ); - // Set up cytoscape event handlers - useEffect(() => { + layout.run(); + } + }; + const layoutstopHandler: cytoscape.EventHandler = event => { + event.cy.animate({ + ...animationOptions, + center: { + eles: serviceName + ? event.cy.getElementById(serviceName) + : event.cy.collection() + } + }); + }; const mouseoverHandler: cytoscape.EventHandler = event => { + trackApmEvent({ metric: 'service_map_node_or_edge_hover' }); event.target.addClass('hover'); event.target.connectedEdges().addClass('nodeHover'); }; @@ -189,15 +181,24 @@ export function Cytoscape({ event.target.connectedEdges().removeClass('nodeHover'); }; const selectHandler: cytoscape.EventHandler = event => { + trackApmEvent({ metric: 'service_map_node_select' }); resetConnectedEdgeStyle(event.target); }; const unselectHandler: cytoscape.EventHandler = event => { resetConnectedEdgeStyle(); }; + const debugHandler: cytoscape.EventHandler = event => { + const debugEnabled = sessionStorage.getItem('apm_debug') === 'true'; + if (debugEnabled) { + // eslint-disable-next-line no-console + console.debug('cytoscape:', event); + } + }; if (cy) { + cy.on('data layoutstop select unselect', debugHandler); cy.on('data', dataHandler); - cy.ready(dataHandler); + cy.on('layoutstop', layoutstopHandler); cy.on('mouseover', 'edge, node', mouseoverHandler); cy.on('mouseout', 'edge, node', mouseoutHandler); cy.on('select', 'node', selectHandler); @@ -207,15 +208,19 @@ export function Cytoscape({ return () => { if (cy) { cy.removeListener( - 'data', + 'data layoutstop select unselect', undefined, - dataHandler as cytoscape.EventHandler + debugHandler ); + cy.removeListener('data', undefined, dataHandler); + cy.removeListener('layoutstop', undefined, layoutstopHandler); cy.removeListener('mouseover', 'edge, node', mouseoverHandler); cy.removeListener('mouseout', 'edge, node', mouseoutHandler); + cy.removeListener('select', 'node', selectHandler); + cy.removeListener('unselect', 'node', unselectHandler); } }; - }, [cy, dataHandler, resetConnectedEdgeStyle, serviceName]); + }, [cy, height, serviceName, trackApmEvent, width]); return ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx index d61dea80666a0..f05ff0246b9e4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx @@ -7,9 +7,9 @@ import { act, render, wait } from '@testing-library/react'; import cytoscape from 'cytoscape'; import React, { FunctionComponent } from 'react'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; import { CytoscapeContext } from './Cytoscape'; import { EmptyBanner } from './EmptyBanner'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; const cy = cytoscape({}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx index 13aa53a8cf4b2..102b135f3cd1f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -17,6 +17,7 @@ import React, { import { SERVICE_NAME } from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { CytoscapeContext } from '../Cytoscape'; import { Contents } from './Contents'; +import { animationOptions } from '../cytoscapeOptions'; interface PopoverProps { focusedServiceName?: string; @@ -88,7 +89,10 @@ export function Popover({ focusedServiceName }: PopoverProps) { const centerSelectedNode = useCallback(() => { if (cy) { - cy.center(cy.getElementById(selectedNodeServiceName)); + cy.animate({ + ...animationOptions, + center: { eles: cy.getElementById(selectedNodeServiceName) } + }); } }, [cy, selectedNodeServiceName]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index e19cb8ae4b646..413458f336e6f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -7,8 +7,8 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import cytoscape from 'cytoscape'; import { CSSProperties } from 'react'; import { - DESTINATION_ADDRESS, - SERVICE_NAME + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { defaultIcon, iconForNode } from './icons'; @@ -59,7 +59,9 @@ const style: cytoscape.Stylesheet[] = [ 'ghost-opacity': 0.15, height: nodeHeight, label: (el: cytoscape.NodeSingular) => - isService(el) ? el.data(SERVICE_NAME) : el.data(DESTINATION_ADDRESS), + isService(el) + ? el.data(SERVICE_NAME) + : el.data(SPAN_DESTINATION_SERVICE_RESOURCE), 'min-zoomed-font-size': theme.euiSizeL, 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => @@ -113,11 +115,6 @@ const style: cytoscape.Stylesheet[] = [ selector: 'edge[isInverseEdge]', style: { visibility: 'hidden' } }, - // @ts-ignore - { - selector: '.invisible', - style: { visibility: 'hidden' } - }, { selector: 'edge.nodeHover', style: { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index 5102dfc02f757..4925ffba310b5 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -8,12 +8,14 @@ import cytoscape from 'cytoscape'; import { AGENT_NAME, SERVICE_NAME, - SPAN_TYPE + SPAN_TYPE, + SPAN_SUBTYPE } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import databaseIcon from './icons/database.svg'; import defaultIconImport from './icons/default.svg'; import documentsIcon from './icons/documents.svg'; import dotNetIcon from './icons/dot-net.svg'; +import elasticsearchIcon from './icons/elasticsearch.svg'; import globeIcon from './icons/globe.svg'; import goIcon from './icons/go.svg'; import javaIcon from './icons/java.svg'; @@ -63,6 +65,11 @@ export function iconForNode(node: cytoscape.NodeSingular) { return serviceIcons[node.data(AGENT_NAME) as string]; } else if (isIE11) { return defaultIcon; + } else if ( + node.data(SPAN_TYPE) === 'db' && + node.data(SPAN_SUBTYPE) === 'elasticsearch' + ) { + return elasticsearchIcon; } else if (icons[type]) { return icons[type]; } else { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg new file mode 100644 index 0000000000000..4f9fda36ba06a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg @@ -0,0 +1 @@ + diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx index 926f53954e7c6..d93caa601f0b6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx @@ -8,8 +8,8 @@ import { render } from '@testing-library/react'; import React, { FunctionComponent } from 'react'; import { License } from '../../../../../../../plugins/licensing/common/license'; import { LicenseContext } from '../../../context/LicenseContext'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; import { ServiceMap } from './'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; const expiredLicense = new License({ signature: 'test signature', diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 4974553f6ca93..0abaa9d76fc07 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -23,6 +23,7 @@ import { EmptyBanner } from './EmptyBanner'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; import { BetaBadge } from './BetaBadge'; +import { useTrackPageview } from '../../../../../../../plugins/observability/public'; interface ServiceMapProps { serviceName?: string; @@ -30,7 +31,7 @@ interface ServiceMapProps { export function ServiceMap({ serviceName }: ServiceMapProps) { const license = useLicense(); - const { urlParams, uiFilters } = useUrlParams(); + const { urlParams } = useUrlParams(); const { data } = useFetcher(() => { const { start, end, environment } = urlParams; @@ -42,19 +43,18 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { start, end, environment, - serviceName, - uiFilters: JSON.stringify({ - ...uiFilters, - environment: undefined - }) + serviceName } } }); } - }, [serviceName, uiFilters, urlParams]); + }, [serviceName, urlParams]); const { ref, height, width } = useRefDimensions(); + useTrackPageview({ app: 'apm', path: 'service_map' }); + useTrackPageview({ app: 'apm', path: 'service_map', delay: 15000 }); + if (!license) { return null; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx index 0ec9e90a31659..eced7457318d8 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ServiceNodeMetrics } from '.'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('ServiceNodeMetrics', () => { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index 241f272b54a1d..b286d33ca74e9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -11,11 +11,11 @@ import * as urlParamsHooks from '../../../../hooks/useUrlParams'; import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock'; +import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; import { MockApmPluginContextWrapper, mockApmPluginContextValue -} from '../../../../utils/testHelpers'; -import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; +} from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx deleted file mode 100644 index 997df371b51ed..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { NotificationsStart } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; -import { Config } from '../index'; -import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { callApmApi } from '../../../../../services/rest/createCallApmApi'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; - -interface Props { - onDeleted: () => void; - selectedConfig: Config; -} - -export function DeleteButton({ onDeleted, selectedConfig }: Props) { - const [isDeleting, setIsDeleting] = useState(false); - const { toasts } = useApmPluginContext().core.notifications; - - return ( - { - setIsDeleting(true); - await deleteConfig(selectedConfig, toasts); - setIsDeleting(false); - onDeleted(); - }} - > - {i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel', - { defaultMessage: 'Delete' } - )} - - ); -} - -async function deleteConfig( - selectedConfig: Config, - toasts: NotificationsStart['toasts'] -) { - try { - await callApmApi({ - pathname: '/api/apm/settings/agent-configuration', - method: 'DELETE', - params: { - body: { - service: { - name: selectedConfig.service.name, - environment: selectedConfig.service.environment - } - } - } - }); - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle', - { defaultMessage: 'Configuration was deleted' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText', - { - defaultMessage: - 'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.', - values: { serviceName: getOptionLabel(selectedConfig.service.name) } - } - ) - }); - } catch (error) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle', - { defaultMessage: 'Configuration could not be deleted' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText', - { - defaultMessage: - 'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"', - values: { - serviceName: getOptionLabel(selectedConfig.service.name), - errorMessage: error.message - } - } - ) - }); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx deleted file mode 100644 index 537bdace50e24..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx +++ /dev/null @@ -1,159 +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 { EuiTitle, EuiSpacer, EuiFormRow, EuiText } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - omitAllOption, - getOptionLabel -} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { useFetcher } from '../../../../../hooks/useFetcher'; -import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; - -const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder', - { defaultMessage: 'Select' } -)} -`; - -interface Props { - isReadOnly: boolean; - serviceName: string; - onServiceNameChange: (env: string) => void; - environment: string; - onEnvironmentChange: (env: string) => void; -} - -export function ServiceSection({ - isReadOnly, - serviceName, - onServiceNameChange, - environment, - onEnvironmentChange -}: Props) { - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( - callApmApi => { - if (!isReadOnly) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/services', - forceCache: true - }); - } - }, - [isReadOnly], - { preservePreviousData: false } - ); - const { data: environments = [], status: environmentStatus } = useFetcher( - callApmApi => { - if (!isReadOnly && serviceName) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/environments', - params: { query: { serviceName: omitAllOption(serviceName) } } - }); - } - }, - [isReadOnly, serviceName], - { preservePreviousData: false } - ); - - const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption', - { defaultMessage: 'already configured' } - ); - - const serviceNameOptions = serviceNames.map(name => ({ - text: getOptionLabel(name), - value: name - })); - const environmentOptions = environments.map( - ({ name, alreadyConfigured }) => ({ - disabled: alreadyConfigured, - text: `${getOptionLabel(name)} ${ - alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' - }`, - value: name - }) - ); - - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.title', - { defaultMessage: 'Service' } - )} -

-
- - - - - {isReadOnly ? ( - {getOptionLabel(serviceName)} - ) : ( - { - e.preventDefault(); - onServiceNameChange(e.target.value); - onEnvironmentChange(''); - }} - /> - )} - - - - {isReadOnly ? ( - {getOptionLabel(environment)} - ) : ( - { - e.preventDefault(); - onEnvironmentChange(e.target.value); - }} - /> - )} - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx deleted file mode 100644 index 24c8222d4cd99..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - EuiFormRow, - EuiFieldText, - EuiTitle, - EuiSpacer, - EuiFieldNumber -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; -import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; - -interface Props { - isRumService: boolean; - - // sampleRate - sampleRate: string; - setSampleRate: (value: string) => void; - isSampleRateValid?: boolean; - - // captureBody - captureBody: string; - setCaptureBody: (value: string) => void; - - // transactionMaxSpans - transactionMaxSpans: string; - setTransactionMaxSpans: (value: string) => void; - isTransactionMaxSpansValid?: boolean; -} - -export function SettingsSection({ - isRumService, - - // sampleRate - sampleRate, - setSampleRate, - isSampleRateValid, - - // captureBody - captureBody, - setCaptureBody, - - // transactionMaxSpans - transactionMaxSpans, - setTransactionMaxSpans, - isTransactionMaxSpansValid -}: Props) { - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.title', - { defaultMessage: 'Options' } - )} -

-
- - - - - { - e.preventDefault(); - setSampleRate(e.target.value); - }} - /> - - - - - {!isRumService && ( - - { - e.preventDefault(); - setCaptureBody(e.target.value); - }} - /> - - )} - - {!isRumService && ( - - { - e.preventDefault(); - setTransactionMaxSpans(e.target.value); - }} - /> - - )} - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx deleted file mode 100644 index a034ca543390f..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx +++ /dev/null @@ -1,256 +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 { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiForm, - EuiPortal, - EuiTitle, - EuiText, - EuiSpacer -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { isRight } from 'fp-ts/lib/Either'; -import { transactionSampleRateRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_sample_rate_rt'; -import { Config } from '../index'; -import { SettingsSection } from './SettingsSection'; -import { ServiceSection } from './ServiceSection'; -import { DeleteButton } from './DeleteButton'; -import { transactionMaxSpansRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_max_spans_rt'; -import { useFetcher } from '../../../../../hooks/useFetcher'; -import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name'; -import { ALL_OPTION_VALUE } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { saveConfig } from './saveConfig'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; -import { useUiTracker } from '../../../../../../../../../plugins/observability/public'; - -const defaultSettings = { - TRANSACTION_SAMPLE_RATE: '1.0', - CAPTURE_BODY: 'off', - TRANSACTION_MAX_SPANS: '500' -}; - -interface Props { - onClose: () => void; - onSaved: () => void; - onDeleted: () => void; - selectedConfig: Config | null; -} - -export function AddEditFlyout({ - onClose, - onSaved, - onDeleted, - selectedConfig -}: Props) { - const { toasts } = useApmPluginContext().core.notifications; - const [isSaving, setIsSaving] = useState(false); - - // get a telemetry UI event tracker - const trackApmEvent = useUiTracker({ app: 'apm' }); - - // config conditions (service) - const [serviceName, setServiceName] = useState( - selectedConfig ? selectedConfig.service.name || ALL_OPTION_VALUE : '' - ); - const [environment, setEnvironment] = useState( - selectedConfig ? selectedConfig.service.environment || ALL_OPTION_VALUE : '' - ); - - const { data: { agentName } = { agentName: undefined } } = useFetcher( - callApmApi => { - if (serviceName === ALL_OPTION_VALUE) { - return Promise.resolve({ agentName: undefined }); - } - - if (serviceName) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/agent_name', - params: { query: { serviceName } } - }); - } - }, - [serviceName], - { preservePreviousData: false } - ); - - // config settings - const [sampleRate, setSampleRate] = useState( - ( - selectedConfig?.settings.transaction_sample_rate || - defaultSettings.TRANSACTION_SAMPLE_RATE - ).toString() - ); - const [captureBody, setCaptureBody] = useState( - selectedConfig?.settings.capture_body || defaultSettings.CAPTURE_BODY - ); - const [transactionMaxSpans, setTransactionMaxSpans] = useState( - ( - selectedConfig?.settings.transaction_max_spans || - defaultSettings.TRANSACTION_MAX_SPANS - ).toString() - ); - - const isRumService = isRumAgentName(agentName); - const isSampleRateValid = isRight(transactionSampleRateRt.decode(sampleRate)); - const isTransactionMaxSpansValid = isRight( - transactionMaxSpansRt.decode(transactionMaxSpans) - ); - - const isFormValid = - !!serviceName && - !!environment && - isSampleRateValid && - // captureBody and isTransactionMaxSpansValid are required except if service is RUM - (isRumService || (!!captureBody && isTransactionMaxSpansValid)) && - // agent name is required, except if serviceName is "all" - (serviceName === ALL_OPTION_VALUE || agentName !== undefined); - - const handleSubmitEvent = async ( - event: - | React.FormEvent - | React.MouseEvent - ) => { - event.preventDefault(); - setIsSaving(true); - - await saveConfig({ - serviceName, - environment, - sampleRate, - captureBody, - transactionMaxSpans, - agentName, - isExistingConfig: Boolean(selectedConfig), - toasts, - trackApmEvent - }); - setIsSaving(false); - onSaved(); - }; - - return ( - - - - -

- {selectedConfig - ? i18n.translate( - 'xpack.apm.settings.agentConf.editConfigTitle', - { defaultMessage: 'Edit configuration' } - ) - : i18n.translate( - 'xpack.apm.settings.agentConf.createConfigTitle', - { defaultMessage: 'Create configuration' } - )} -

-
-
- - - This allows you to fine-tune your agent configuration directly in - Kibana. Best of all, changes are automatically propagated to your - APM agents so there’s no need to redeploy. - - - - - - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} -
{ - const didClickEnter = e.which === 13; - if (didClickEnter) { - handleSubmitEvent(e); - } - }} - > - - - - - - -
-
- - - - {selectedConfig ? ( - - ) : null} - - - - - - {i18n.translate( - 'xpack.apm.settings.agentConf.cancelButtonLabel', - { defaultMessage: 'Cancel' } - )} - - - - - {i18n.translate( - 'xpack.apm.settings.agentConf.saveConfigurationButtonLabel', - { defaultMessage: 'Save' } - )} - - - - - - -
-
- ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts deleted file mode 100644 index 229394cb5da8c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts +++ /dev/null @@ -1,107 +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 { i18n } from '@kbn/i18n'; -import { NotificationsStart } from 'kibana/public'; -import { callApmApi } from '../../../../../services/rest/createCallApmApi'; -import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name'; -import { - getOptionLabel, - omitAllOption -} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { UiTracker } from '../../../../../../../../../plugins/observability/public'; - -interface Settings { - transaction_sample_rate: number; - capture_body?: string; - transaction_max_spans?: number; -} - -export async function saveConfig({ - serviceName, - environment, - sampleRate, - captureBody, - transactionMaxSpans, - agentName, - isExistingConfig, - toasts, - trackApmEvent -}: { - serviceName: string; - environment: string; - sampleRate: string; - captureBody: string; - transactionMaxSpans: string; - agentName?: string; - isExistingConfig: boolean; - toasts: NotificationsStart['toasts']; - trackApmEvent: UiTracker; -}) { - trackApmEvent({ metric: 'save_agent_configuration' }); - - try { - const settings: Settings = { - transaction_sample_rate: Number(sampleRate) - }; - - if (!isRumAgentName(agentName)) { - settings.capture_body = captureBody; - settings.transaction_max_spans = Number(transactionMaxSpans); - } - - const configuration = { - agent_name: agentName, - service: { - name: omitAllOption(serviceName), - environment: omitAllOption(environment) - }, - settings - }; - - await callApmApi({ - pathname: '/api/apm/settings/agent-configuration', - method: 'PUT', - params: { - query: { overwrite: isExistingConfig }, - body: configuration - } - }); - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.succeeded.title', - { defaultMessage: 'Configuration saved' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.succeeded.text', - { - defaultMessage: - 'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.', - values: { serviceName: getOptionLabel(serviceName) } - } - ) - }); - } catch (error) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.failed.title', - { defaultMessage: 'Configuration could not be saved' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.failed.text', - { - defaultMessage: - 'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"', - values: { - serviceName: getOptionLabel(serviceName), - errorMessage: error.message - } - } - ) - }); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx new file mode 100644 index 0000000000000..30d3f9580db48 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx @@ -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 React from 'react'; +import { + EuiDescribedFormGroup, + EuiSelectOption, + EuiFormRow +} from '@elastic/eui'; +import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; + +interface Props { + title: string; + description: string; + fieldLabel: string; + isLoading: boolean; + options?: EuiSelectOption[]; + value?: string; + disabled: boolean; + onChange: (event: React.ChangeEvent) => void; +} + +export function FormRowSelect({ + title, + description, + fieldLabel, + isLoading, + options, + value, + disabled, + onChange +}: Props) { + return ( + {title}} + description={description} + > + + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx new file mode 100644 index 0000000000000..b9f8fd86d067b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButton +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { isString } from 'lodash'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + omitAllOption, + getOptionLabel +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { useFetcher, FETCH_STATUS } from '../../../../../../hooks/useFetcher'; +import { FormRowSelect } from './FormRowSelect'; +import { APMLink } from '../../../../../shared/Links/apm/APMLink'; + +interface Props { + newConfig: AgentConfigurationIntake; + setNewConfig: React.Dispatch>; + onClickNext: () => void; +} + +export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { + const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + callApmApi => { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/services', + forceCache: true + }); + }, + [], + { preservePreviousData: false } + ); + + const { data: environments = [], status: environmentStatus } = useFetcher( + callApmApi => { + if (newConfig.service.name) { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/environments', + params: { + query: { serviceName: omitAllOption(newConfig.service.name) } + } + }); + } + }, + [newConfig.service.name], + { preservePreviousData: false } + ); + + const { status: agentNameStatus } = useFetcher( + async callApmApi => { + const serviceName = newConfig.service.name; + + if (!isString(serviceName) || serviceName.length === 0) { + return; + } + + const { agentName } = await callApmApi({ + pathname: '/api/apm/settings/agent-configuration/agent_name', + params: { query: { serviceName } } + }); + + setNewConfig(prev => ({ ...prev, agent_name: agentName })); + }, + [newConfig.service.name, setNewConfig] + ); + + const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( + 'xpack.apm.agentConfig.servicePage.alreadyConfiguredOption', + { defaultMessage: 'already configured' } + ); + + const serviceNameOptions = serviceNames.map(name => ({ + text: getOptionLabel(name), + value: name + })); + const environmentOptions = environments.map( + ({ name, alreadyConfigured }) => ({ + disabled: alreadyConfigured, + text: `${getOptionLabel(name)} ${ + alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' + }`, + value: name + }) + ); + + return ( + + +

+ {i18n.translate('xpack.apm.agentConfig.servicePage.title', { + defaultMessage: 'Choose service' + })} +

+
+ + + + {/* Service name options */} + { + e.preventDefault(); + const name = e.target.value; + setNewConfig(prev => ({ + ...prev, + service: { name, environment: '' } + })); + }} + /> + + {/* Environment options */} + { + e.preventDefault(); + const environment = e.target.value; + setNewConfig(prev => ({ + ...prev, + service: { name: prev.service.name, environment } + })); + }} + /> + + + + + {/* Cancel button */} + + + + {i18n.translate( + 'xpack.apm.agentConfig.servicePage.cancelButton', + { defaultMessage: 'Cancel' } + )} + + + + + {/* Next button */} + + + {i18n.translate( + 'xpack.apm.agentConfig.saveConfigurationButtonLabel', + { defaultMessage: 'Next step' } + )} + + + +
+ ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx new file mode 100644 index 0000000000000..b1959e4d68aa4 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiFormRow, + EuiFieldText, + EuiFieldNumber, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiCode, + EuiSpacer, + EuiIconTip +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SettingDefinition } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions/types'; +import { isValid } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { + amountAndUnitToString, + amountAndUnitToObject +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/amount_and_unit'; +import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; + +function FormRow({ + setting, + value, + onChange +}: { + setting: SettingDefinition; + value?: string; + onChange: (key: string, value: string) => void; +}) { + switch (setting.type) { + case 'float': + case 'text': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'integer': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'select': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'boolean': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'bytes': + case 'duration': { + const { amount, unit } = amountAndUnitToObject(value ?? ''); + + return ( + + + + onChange( + setting.key, + amountAndUnitToString({ amount: e.target.value, unit }) + ) + } + /> + + + ({ text }))} + onChange={e => + onChange( + setting.key, + amountAndUnitToString({ amount, unit: e.target.value }) + ) + } + /> + + + ); + } + + default: + throw new Error(`Unknown type "${(setting as SettingDefinition).type}"`); + } +} + +export function SettingFormRow({ + isUnsaved, + setting, + value, + onChange +}: { + isUnsaved: boolean; + setting: SettingDefinition; + value?: string; + onChange: (key: string, value: string) => void; +}) { + const isInvalid = value != null && value !== '' && !isValid(setting, value); + + return ( + + {setting.label}{' '} + {isUnsaved && ( + + )} + + } + description={ + <> + {setting.description} + + {setting.defaultValue && ( + <> + + Default: {setting.defaultValue} + + )} + + } + > + + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000000000..6d76b69600333 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.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 { + EuiButton, + EuiForm, + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiBottomBar, + EuiText, + EuiHealth, + EuiLoadingSpinner +} from '@elastic/eui'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiCallOut } from '@elastic/eui'; +import { FETCH_STATUS } from '../../../../../../hooks/useFetcher'; +import { AgentName } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/fields/agent'; +import { history } from '../../../../../../utils/history'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + filterByAgent, + settingDefinitions, + isValid +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { saveConfig } from './saveConfig'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { useUiTracker } from '../../../../../../../../../../plugins/observability/public'; +import { SettingFormRow } from './SettingFormRow'; +import { getOptionLabel } from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; + +function removeEmpty(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter(([k, v]) => v != null && v !== '') + ); +} + +export function SettingsPage({ + status, + unsavedChanges, + newConfig, + setNewConfig, + resetSettings, + isEditMode, + onClickEdit +}: { + status?: FETCH_STATUS; + unsavedChanges: Record; + newConfig: AgentConfigurationIntake; + setNewConfig: React.Dispatch>; + resetSettings: () => void; + isEditMode: boolean; + onClickEdit: () => void; +}) { + // get a telemetry UI event tracker + const trackApmEvent = useUiTracker({ app: 'apm' }); + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + const unsavedChangesCount = Object.keys(unsavedChanges).length; + const isLoading = status === FETCH_STATUS.LOADING; + + const isFormValid = useMemo(() => { + return ( + settingDefinitions + // only validate settings that are not empty + .filter(({ key }) => { + const value = newConfig.settings[key]; + return value != null && value !== ''; + }) + + // every setting must be valid for the form to be valid + .every(def => { + const value = newConfig.settings[def.key]; + return isValid(def, value); + }) + ); + }, [newConfig.settings]); + + const handleSubmitEvent = async () => { + trackApmEvent({ metric: 'save_agent_configuration' }); + const config = { ...newConfig, settings: removeEmpty(newConfig.settings) }; + + setIsSaving(true); + await saveConfig({ config, isEditMode, toasts }); + setIsSaving(false); + + // go back to overview + history.push({ + pathname: '/settings/agent-configuration', + search: history.location.search + }); + }; + + if (status === FETCH_STATUS.FAILURE) { + return ( + +

+ {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.notFound.message', + { defaultMessage: 'The requested configuration does not exist' } + )} +

+
+ ); + } + + return ( + <> + + {/* Since the submit button is placed outside the form we cannot use `onSubmit` and have to use `onKeyPress` to submit the form on enter */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
{ + const didClickEnter = e.which === 13; + if (didClickEnter && isFormValid) { + e.preventDefault(); + handleSubmitEvent(); + } + }} + > + {/* Selected Service panel */} + + +

+ {i18n.translate('xpack.apm.agentConfig.chooseService.title', { + defaultMessage: 'Choose service' + })} +

+
+ + + + + + + + + + + + {!isEditMode && ( + + {i18n.translate( + 'xpack.apm.agentConfig.chooseService.editButton', + { defaultMessage: 'Edit' } + )} + + )} + + +
+ + + + {/* Settings panel */} + + +

+ {i18n.translate('xpack.apm.agentConfig.settings.title', { + defaultMessage: 'Configuration options' + })} +

+
+ + + + {isLoading ? ( +
+ +
+ ) : ( + renderSettings({ unsavedChanges, newConfig, setNewConfig }) + )} +
+ +
+ + + {/* Bottom bar with save button */} + {unsavedChangesCount > 0 && ( + + + + + + {i18n.translate('xpack.apm.unsavedChanges', { + defaultMessage: + '{unsavedChangesCount, plural, =0{0 unsaved changes} one {1 unsaved change} other {# unsaved changes}} ', + values: { unsavedChangesCount } + })} + + + + + + + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.discardChangesButton', + { defaultMessage: 'Discard changes' } + )} + + + + + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.saveButton', + { defaultMessage: 'Save configuration' } + )} + + + + + + + )} + + ); +} + +function renderSettings({ + newConfig, + unsavedChanges, + setNewConfig +}: { + newConfig: AgentConfigurationIntake; + unsavedChanges: Record; + setNewConfig: React.Dispatch>; +}) { + return ( + settingDefinitions + + // filter out agent specific items that are not applicable + // to the selected service + .filter(filterByAgent(newConfig.agent_name as AgentName)) + .map(setting => ( + { + setNewConfig(prev => ({ + ...prev, + settings: { + ...prev.settings, + [key]: value + } + })); + }} + /> + )) + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts new file mode 100644 index 0000000000000..7e3bcd68699be --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.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 { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + getOptionLabel, + omitAllOption +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; + +export async function saveConfig({ + config, + isEditMode, + toasts +}: { + config: AgentConfigurationIntake; + agentName?: string; + isEditMode: boolean; + toasts: NotificationsStart['toasts']; +}) { + try { + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration', + method: 'PUT', + params: { + query: { overwrite: isEditMode }, + body: { + ...config, + service: { + name: omitAllOption(config.service.name), + environment: omitAllOption(config.service.environment) + } + } + } + }); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.agentConfig.saveConfig.succeeded.title', + { defaultMessage: 'Configuration saved' } + ), + text: i18n.translate('xpack.apm.agentConfig.saveConfig.succeeded.text', { + defaultMessage: + 'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.', + values: { serviceName: getOptionLabel(config.service.name) } + }) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate('xpack.apm.agentConfig.saveConfig.failed.title', { + defaultMessage: 'Configuration could not be saved' + }), + text: i18n.translate('xpack.apm.agentConfig.saveConfig.failed.text', { + defaultMessage: + 'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"', + values: { + serviceName: getOptionLabel(config.service.name), + errorMessage: error.message + } + }) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx new file mode 100644 index 0000000000000..531e557b6ef86 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { HttpSetup } from 'kibana/public'; +import { AgentConfiguration } from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { createCallApmApi } from '../../../../../services/rest/createCallApmApi'; +import { AgentConfigurationCreateEdit } from './index'; +import { + ApmPluginContext, + ApmPluginContextValue +} from '../../../../../context/ApmPluginContext'; + +storiesOf( + 'app/Settings/AgentConfigurations/AgentConfigurationCreateEdit', + module +).add( + 'with config', + () => { + const httpMock = {}; + + // mock + createCallApmApi((httpMock as unknown) as HttpSetup); + + const contextMock = { + core: { + notifications: { toasts: { addWarning: () => {}, addDanger: () => {} } } + } + }; + return ( + + + + ); + }, + { + info: { + source: false + } + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx new file mode 100644 index 0000000000000..638e518563f8c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FetcherResult } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { + AgentConfigurationIntake, + AgentConfiguration +} from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { ServicePage } from './ServicePage/ServicePage'; +import { SettingsPage } from './SettingsPage/SettingsPage'; +import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers'; + +type PageStep = 'choose-service-step' | 'choose-settings-step' | 'review-step'; + +function getInitialNewConfig( + existingConfig: AgentConfigurationIntake | undefined +) { + return { + agent_name: existingConfig?.agent_name, + service: existingConfig?.service || {}, + settings: existingConfig?.settings || {} + }; +} + +function setPage(pageStep: PageStep) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + pageStep + }) + }); +} + +function getUnsavedChanges({ + newConfig, + existingConfig +}: { + newConfig: AgentConfigurationIntake; + existingConfig?: AgentConfigurationIntake; +}) { + return Object.fromEntries( + Object.entries(newConfig.settings).filter(([key, value]) => { + const existingValue = existingConfig?.settings?.[key]; + + // don't highlight changes that were added and removed + if (value === '' && existingValue == null) { + return false; + } + + return existingValue !== value; + }) + ); +} + +export function AgentConfigurationCreateEdit({ + pageStep, + existingConfigResult +}: { + pageStep: PageStep; + existingConfigResult?: FetcherResult; +}) { + const existingConfig = existingConfigResult?.data; + const isEditMode = Boolean(existingConfigResult); + const [newConfig, setNewConfig] = useState( + getInitialNewConfig(existingConfig) + ); + + const resetSettings = useCallback(() => { + setNewConfig(_newConfig => ({ + ..._newConfig, + settings: existingConfig?.settings || {} + })); + }, [existingConfig]); + + // update newConfig when existingConfig has loaded + useEffect(() => { + setNewConfig(getInitialNewConfig(existingConfig)); + }, [existingConfig]); + + useEffect(() => { + // the user tried to edit the service of an existing config + if (pageStep === 'choose-service-step' && isEditMode) { + setPage('choose-settings-step'); + } + + // the user skipped the first step (select service) + if ( + pageStep === 'choose-settings-step' && + !isEditMode && + isEmpty(newConfig.service) + ) { + setPage('choose-service-step'); + } + }, [isEditMode, newConfig, pageStep]); + + const unsavedChanges = getUnsavedChanges({ newConfig, existingConfig }); + + return ( + <> + +

+ {isEditMode + ? i18n.translate('xpack.apm.agentConfig.editConfigTitle', { + defaultMessage: 'Edit configuration' + }) + : i18n.translate('xpack.apm.agentConfig.createConfigTitle', { + defaultMessage: 'Create configuration' + })} +

+
+ + + {i18n.translate('xpack.apm.agentConfig.newConfig.description', { + defaultMessage: `This allows you to fine-tune your agent configuration directly in + Kibana. Best of all, changes are automatically propagated to your APM + agents so there’s no need to redeploy.` + })} + + + + + {pageStep === 'choose-service-step' && ( + setPage('choose-settings-step')} + /> + )} + + {pageStep === 'choose-settings-step' && ( + setPage('choose-service-step')} + newConfig={newConfig} + setNewConfig={setNewConfig} + resetSettings={resetSettings} + isEditMode={isEditMode} + /> + )} + + {/* + TODO: Add review step + {pageStep === 'review-step' &&
Review will be here
} + */} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx deleted file mode 100644 index 557945e9ba67a..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiEmptyPrompt, - EuiButton, - EuiButtonEmpty, - EuiHealth, - EuiToolTip -} from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { Config } from '.'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; -import { px, units } from '../../../../style/variables'; -import { getOptionLabel } from '../../../../../../../../plugins/apm/common/agent_configuration_constants'; - -export function AgentConfigurationList({ - status, - data, - setIsFlyoutOpen, - setSelectedConfig -}: { - status: FETCH_STATUS; - data: AgentConfigurationListAPIResponse; - setIsFlyoutOpen: (val: boolean) => void; - setSelectedConfig: (val: Config | null) => void; -}) { - const columns: Array> = [ - { - field: 'applied_by_agent', - align: 'center', - width: px(units.double), - name: '', - sortable: true, - render: (isApplied: boolean) => ( - - - - ) - }, - { - field: 'service.name', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel', - { defaultMessage: 'Service name' } - ), - sortable: true, - render: (_, config: Config) => ( - { - setSelectedConfig(config); - setIsFlyoutOpen(true); - }} - > - {getOptionLabel(config.service.name)} - - ) - }, - { - field: 'service.environment', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.environmentColumnLabel', - { defaultMessage: 'Service environment' } - ), - sortable: true, - render: (value: string) => getOptionLabel(value) - }, - { - field: 'settings.transaction_sample_rate', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel', - { defaultMessage: 'Sample rate' } - ), - dataType: 'number', - sortable: true, - render: (value: number) => value - }, - { - field: 'settings.capture_body', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel', - { defaultMessage: 'Capture body' } - ), - sortable: true, - render: (value: string) => value - }, - { - field: 'settings.transaction_max_spans', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel', - { defaultMessage: 'Transaction max spans' } - ), - dataType: 'number', - sortable: true, - render: (value: number) => value - }, - { - align: 'right', - field: '@timestamp', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel', - { defaultMessage: 'Last updated' } - ), - sortable: true, - render: (value: number) => ( - - ) - }, - { - width: px(units.double), - name: '', - actions: [ - { - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.editButtonLabel', - { defaultMessage: 'Edit' } - ), - description: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.editButtonDescription', - { defaultMessage: 'Edit this config' } - ), - icon: 'pencil', - color: 'primary', - type: 'icon', - onClick: (config: Config) => { - setSelectedConfig(config); - setIsFlyoutOpen(true); - } - } - ] - } - ]; - - const emptyStatePrompt = ( - - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.emptyPromptTitle', - { defaultMessage: 'No configurations found.' } - )} - - } - body={ - <> -

- {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.emptyPromptText', - { - defaultMessage: - "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." - } - )} -

- - } - actions={ - setIsFlyoutOpen(true)}> - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} - - } - /> - ); - - const failurePrompt = ( - -

- {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.configTable.failurePromptText', - { - defaultMessage: - 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' - } - )} -

- - } - /> - ); - - if (status === 'failure') { - return failurePrompt; - } - - if (status === 'success' && isEmpty(data)) { - return emptyStatePrompt; - } - - return ( - } - columns={columns} - items={data} - initialSortField="service.name" - initialSortDirection="asc" - initialPageSize={50} - /> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx new file mode 100644 index 0000000000000..267aaddc93f76 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { NotificationsStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; +import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { callApmApi } from '../../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; + +type Config = AgentConfigurationListAPIResponse[0]; + +interface Props { + config: Config; + onCancel: () => void; + onConfirm: () => void; +} + +export function ConfirmDeleteModal({ config, onCancel, onConfirm }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + const { toasts } = useApmPluginContext().core.notifications; + + return ( + + { + setIsDeleting(true); + await deleteConfig(config, toasts); + setIsDeleting(false); + onConfirm(); + }} + cancelButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.cancel', + { defaultMessage: `Cancel` } + )} + confirmButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.confirm', + { defaultMessage: `Delete` } + )} + confirmButtonDisabled={isDeleting} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

+ {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { + defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, + values: { + serviceName: getOptionLabel(config.service.name), + environment: getOptionLabel(config.service.environment) + } + })} +

+
+
+ ); +} + +async function deleteConfig( + config: Config, + toasts: NotificationsStart['toasts'] +) { + try { + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration', + method: 'DELETE', + params: { + body: { + service: { + name: config.service.name, + environment: config.service.environment + } + } + } + }); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigSucceededTitle', + { defaultMessage: 'Configuration was deleted' } + ), + text: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigSucceededText', + { + defaultMessage: + 'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.', + values: { serviceName: getOptionLabel(config.service.name) } + } + ) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigFailedTitle', + { defaultMessage: 'Configuration could not be deleted' } + ), + text: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigFailedText', + { + defaultMessage: + 'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"', + values: { + serviceName: getOptionLabel(config.service.name), + errorMessage: error.message + } + } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx new file mode 100644 index 0000000000000..6d5f65121d8fd --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { i18n } from '@kbn/i18n'; +import { + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, + EuiHealth, + EuiToolTip, + EuiButtonIcon +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { px, units } from '../../../../../style/variables'; +import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { + createAgentConfigurationHref, + editAgentConfigurationHref +} from '../../../../shared/Links/apm/agentConfigurationLinks'; +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; + +type Config = AgentConfigurationListAPIResponse[0]; + +export function AgentConfigurationList({ + status, + data, + refetch +}: { + status: FETCH_STATUS; + data: Config[]; + refetch: () => void; +}) { + const [configToBeDeleted, setConfigToBeDeleted] = useState( + null + ); + + const emptyStatePrompt = ( + + {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptTitle', + { defaultMessage: 'No configurations found.' } + )} + + } + body={ + <> +

+ {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptText', + { + defaultMessage: + "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." + } + )} +

+ + } + actions={ + + {i18n.translate( + 'xpack.apm.agentConfig.configTable.createConfigButtonLabel', + { defaultMessage: 'Create configuration' } + )} + + } + /> + ); + + const failurePrompt = ( + +

+ {i18n.translate( + 'xpack.apm.agentConfig.configTable.configTable.failurePromptText', + { + defaultMessage: + 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' + } + )} +

+ + } + /> + ); + + if (status === FETCH_STATUS.FAILURE) { + return failurePrompt; + } + + if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + return emptyStatePrompt; + } + + const columns: Array> = [ + { + field: 'applied_by_agent', + align: 'center', + width: px(units.double), + name: '', + sortable: true, + render: (isApplied: boolean) => ( + + + + ) + }, + { + field: 'service.name', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.serviceNameColumnLabel', + { defaultMessage: 'Service name' } + ), + sortable: true, + render: (_, config: Config) => ( + + {getOptionLabel(config.service.name)} + + ) + }, + { + field: 'service.environment', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.environmentColumnLabel', + { defaultMessage: 'Service environment' } + ), + sortable: true, + render: (environment: string) => getOptionLabel(environment) + }, + { + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + setConfigToBeDeleted(config)} + /> + ) + } + ]; + + return ( + <> + {configToBeDeleted && ( + setConfigToBeDeleted(null)} + onConfirm={() => { + setConfigToBeDeleted(null); + refetch(); + }} + /> + )} + + } + columns={columns} + items={data} + initialSortField="service.name" + initialSortDirection="asc" + initialPageSize={20} + /> + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 35cc68547d337..8171e339adc82 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTitle, @@ -16,97 +16,59 @@ import { } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { useFetcher } from '../../../../hooks/useFetcher'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { AgentConfigurationList } from './AgentConfigurationList'; +import { AgentConfigurationList } from './List'; import { useTrackPageview } from '../../../../../../../../plugins/observability/public'; -import { AddEditFlyout } from './AddEditFlyout'; - -export type Config = AgentConfigurationListAPIResponse[0]; +import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; export function AgentConfigurations() { - const { data = [], status, refetch } = useFetcher( + const { refetch, data = [], status } = useFetcher( callApmApi => - callApmApi({ pathname: `/api/apm/settings/agent-configuration` }), + callApmApi({ pathname: '/api/apm/settings/agent-configuration' }), [], { preservePreviousData: false } ); - const [selectedConfig, setSelectedConfig] = useState(null); - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); useTrackPageview({ app: 'apm', path: 'agent_configuration' }); useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); const hasConfigurations = !isEmpty(data); - const onClose = () => { - setSelectedConfig(null); - setIsFlyoutOpen(false); - }; - return ( <> - {isFlyoutOpen && ( - { - onClose(); - refetch(); - }} - onDeleted={() => { - onClose(); - refetch(); - }} - /> - )} -

{i18n.translate( - 'xpack.apm.settings.agentConf.configurationsPanelTitle', + 'xpack.apm.agentConfig.configurationsPanelTitle', { defaultMessage: 'Agent remote configuration' } )}

- {hasConfigurations ? ( - setIsFlyoutOpen(true)} /> - ) : null} + {hasConfigurations ? : null}
- +
); } -function CreateConfigurationButton({ onClick }: { onClick: () => void }) { +function CreateConfigurationButton() { + const href = createAgentConfigurationHref(); return ( - - {i18n.translate( - 'xpack.apm.settings.agentConf.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} + + {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { + defaultMessage: 'Create configuration' + })} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index fd71bf9709ce9..272c4b3add415 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -7,8 +7,8 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; import { ApmIndices } from '.'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('ApmIndices', () => { it('should not get stuck in infinite loop', async () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 7c39356189891..b5bee5a5a1ebb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -13,11 +13,11 @@ import * as hooks from '../../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../../context/LicenseContext'; import { CustomLinkOverview } from '.'; import { - MockApmPluginContextWrapper, expectTextsInDocument, expectTextsNotInDocument } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const data = [ { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx index eef386731c5c3..f33bb17decd4e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx @@ -39,23 +39,20 @@ export const Settings: React.FC = props => { id: 0, items: [ { - name: i18n.translate( - 'xpack.apm.settings.agentConfiguration', - { - defaultMessage: 'Agent Configuration' - } - ), + name: i18n.translate('xpack.apm.settings.agentConfig', { + defaultMessage: 'Agent Configuration' + }), id: '1', - // @ts-ignore href: getAPMHref('/settings/agent-configuration', search), - isSelected: pathname === '/settings/agent-configuration' + isSelected: pathname.startsWith( + '/settings/agent-configuration' + ) }, { name: i18n.translate('xpack.apm.settings.indices', { defaultMessage: 'Indices' }), id: '2', - // @ts-ignore href: getAPMHref('/settings/apm-indices', search), isSelected: pathname === '/settings/apm-indices' }, @@ -64,7 +61,6 @@ export const Settings: React.FC = props => { defaultMessage: 'Customize UI' }), id: '3', - // @ts-ignore href: getAPMHref('/settings/customize-ui', search), isSelected: pathname === '/settings/customize-ui' } diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx index fe58fc39c6cfa..b8d6d9818eb2c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { TraceLink } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import * as urlParamsHooks from '../../../../hooks/useUrlParams'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx index 882682f1f6760..22cbeee5c6b7c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx @@ -22,7 +22,7 @@ import * as useFetcherHook from '../../../../hooks/useFetcher'; import { fromQuery } from '../../../shared/Links/url_helpers'; import { Router } from 'react-router-dom'; import { UrlParamsProvider } from '../../../../context/UrlParamsContext'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.spyOn(history, 'push'); jest.spyOn(history, 'replace'); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..4ef8de7c2b208 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.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 { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ErrorRateAlertTrigger } from '.'; + +storiesOf('app/ErrorRateAlertTrigger', module).add('example', props => { + const params = { + threshold: 2, + window: '5m' + }; + + return ( +
+ undefined} + setAlertProperty={() => undefined} + /> +
+ ); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..6d0a2b96092a1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFieldNumber } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG } from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +export interface ErrorRateAlertTriggerParams { + windowSize: number; + windowUnit: string; + threshold: number; +} + +interface Props { + alertParams: ErrorRateAlertTriggerParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function ErrorRateAlertTrigger(props: Props) { + const { setAlertParams, setAlertProperty, alertParams } = props; + + const defaults = { + threshold: 25, + windowSize: 1, + windowUnit: 'm' + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + + setAlertParams('threshold', parseInt(e.target.value, 10)) + } + compressed + append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { + defaultMessage: 'errors' + })} + /> + , + + setAlertParams('windowSize', windowSize) + } + onChangeWindowUnit={windowUnit => + setAlertParams('windowUnit', windowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index eba59f6e3ce44..29b3fff2050c8 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -31,7 +31,7 @@ export const PERSISTENT_APM_PARAMS = [ export function getAPMHref( path: string, - currentSearch: string, // TODO: Replace with passing in URL PARAMS here + currentSearch: string, query: APMQueryParams = {} ) { const currentQuery = toQuery(currentSearch); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx new file mode 100644 index 0000000000000..0c747e0773a69 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.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 { getAPMHref } from './APMLink'; +import { AgentConfigurationIntake } from '../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { history } from '../../../../utils/history'; + +export function editAgentConfigurationHref( + configService: AgentConfigurationIntake['service'] +) { + const { search } = history.location; + return getAPMHref('/settings/agent-configuration/edit', search, { + // ignoring because `name` has not been added to url params. Related: https://github.com/elastic/kibana/issues/51963 + // @ts-ignore + name: configService.name, + environment: configService.environment + }); +} + +export function createAgentConfigurationHref() { + const { search } = history.location; + return getAPMHref('/settings/agent-configuration/create', search); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx index 0c60d523b8f3f..258788252379a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx index ee66636d88ba9..0059b7b8fb4b3 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx @@ -10,9 +10,9 @@ import { SpanMetadata } from '..'; import { Span } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx index f426074fbef80..3d78f36db9786 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx index 979b9118a7534..96202525c8661 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx @@ -7,11 +7,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { MetadataTable } from '..'; -import { - expectTextsInDocument, - MockApmPluginContextWrapper -} from '../../../../utils/testHelpers'; +import { expectTextsInDocument } from '../../../../utils/testHelpers'; import { SectionsWithRows } from '../helper'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx index a8e6bc0a648af..8698978bfe6fb 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx @@ -7,33 +7,38 @@ import React from 'react'; import { EuiSelect } from '@elastic/eui'; import { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; -const NO_SELECTION = 'NO_SELECTION'; +export const NO_SELECTION = '__NO_SELECTION__'; +const DEFAULT_PLACEHOLDER = i18n.translate('xpack.apm.selectPlaceholder', { + defaultMessage: 'Select option:' +}); /** * This component addresses some cross-browser inconsistencies of `EuiSelect` * with `hasNoInitialSelection`. It uses the `placeholder` prop to populate * the first option as the initial, not selected option. */ -export const SelectWithPlaceholder: typeof EuiSelect = props => ( - { - if (props.onChange) { - props.onChange( - Object.assign(e, { +export const SelectWithPlaceholder: typeof EuiSelect = props => { + const placeholder = props.placeholder || DEFAULT_PLACEHOLDER; + return ( + { + if (props.onChange) { + const customEvent = Object.assign(e, { target: Object.assign(e.target, { - value: - e.target.value === NO_SELECTION ? undefined : e.target.value + value: e.target.value === NO_SELECTION ? '' : e.target.value }) - }) - ); - } - }} - /> -); + }); + props.onChange(customEvent); + } + }} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx new file mode 100644 index 0000000000000..1abdb94c8313e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx @@ -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 React, { useState } from 'react'; +import { EuiExpression, EuiPopover } from '@elastic/eui'; + +interface Props { + title: string; + value: string; + children?: React.ReactNode; +} + +export const PopoverExpression = (props: Props) => { + const { title, value, children } = props; + + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + setPopoverOpen(false)} + button={ + setPopoverOpen(true)} + /> + } + > + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx new file mode 100644 index 0000000000000..98391b277caf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import { useUrlParams } from '../../../hooks/useUrlParams'; + +interface Props { + alertTypeName: string; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; + defaults: Record; + fields: React.ReactNode[]; +} + +export function ServiceAlertTrigger(props: Props) { + const { urlParams } = useUrlParams(); + + const { + fields, + setAlertParams, + setAlertProperty, + alertTypeName, + defaults + } = props; + + const params: Record = { + ...defaults, + serviceName: urlParams.serviceName! + }; + + useEffect(() => { + // we only want to run this on mount to set default values + setAlertProperty('name', `${alertTypeName} | ${params.serviceName}`); + setAlertProperty('tags', [ + 'apm', + `service.name:${params.serviceName}`.toLowerCase() + ]); + Object.keys(params).forEach(key => { + setAlertParams(key, params[key]); + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + + + {fields.map((field, index) => ( + + {field} + + ))} + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 9094662e34914..560884aec554a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -10,13 +10,13 @@ import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; import { - MockApmPluginContextWrapper, expectTextsNotInDocument, expectTextsInDocument } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../context/LicenseContext'; import { License } from '../../../../../../../../plugins/licensing/common/license'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderTransaction = async (transaction: Record) => { const rendered = render( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts index be8f379ce62ee..70be1a4744767 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts @@ -8,7 +8,10 @@ import { Location } from 'history'; const bareTransaction = { '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: '8.0.0', + version_major: 8 + }, agent: { name: 'java', version: '7.0.0' diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..a8f834103e6c1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { cloneDeep, merge } from 'lodash'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { TransactionDurationAlertTrigger } from '.'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/MockUrlParamsContextProvider'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; + +storiesOf('app/TransactionDurationAlertTrigger', module).add( + 'example', + context => { + const params = { + threshold: 1500, + aggregationType: 'avg' as const, + window: '5m' + }; + + const contextMock = (merge(cloneDeep(mockApmPluginContextValue), { + core: { + http: { + get: () => { + return Promise.resolve({ transactionTypes: ['request'] }); + } + } + } + }) as unknown) as ApmPluginContextValue; + + return ( +
+ + + undefined} + setAlertProperty={() => undefined} + /> + + +
+ ); + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx new file mode 100644 index 0000000000000..cdc7c30089b4f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.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 { map } from 'lodash'; +import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { + TRANSACTION_ALERT_AGGREGATION_TYPES, + ALERT_TYPES_CONFIG +} from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +interface Params { + windowSize: number; + windowUnit: string; + threshold: number; + aggregationType: 'avg' | '95th' | '99th'; + serviceName: string; + transactionType: string; +} + +interface Props { + alertParams: Params; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionDurationAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + + const { urlParams } = useUrlParams(); + + const transactionTypes = useServiceTransactionTypes(urlParams); + + if (!transactionTypes.length) { + return null; + } + + const defaults = { + threshold: 1500, + aggregationType: 'avg', + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypes[0] + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + { + return { + text: key, + value: key + }; + })} + onChange={e => + setAlertParams( + 'transactionType', + e.target.value as Params['transactionType'] + ) + } + compressed + /> + , + + { + return { + text: label, + value: key + }; + })} + onChange={e => + setAlertParams( + 'aggregationType', + e.target.value as Params['aggregationType'] + ) + } + compressed + /> + , + + setAlertParams('threshold', e.target.value)} + append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { + defaultMessage: 'ms' + })} + compressed + /> + , + + setAlertParams('windowSize', timeWindowSize) + } + onChangeWindowUnit={timeWindowUnit => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx index 6d3e29ec09985..9f112475a4a78 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { BrowserLineChart } from './BrowserLineChart'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('BrowserLineChart', () => { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx new file mode 100644 index 0000000000000..8775dc98c3e1a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ApmPluginContext, ApmPluginContextValue } from '.'; +import { createCallApmApi } from '../../services/rest/createCallApmApi'; +import { ConfigSchema } from '../../new-platform/plugin'; + +const mockCore = { + chrome: { + setBreadcrumbs: () => {} + }, + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + }, + notifications: { + toasts: { + addWarning: () => {}, + addDanger: () => {} + } + } +}; + +const mockConfig: ConfigSchema = { + indexPatternTitle: 'apm-*', + serviceMapEnabled: true, + ui: { + enabled: false + } +}; + +export const mockApmPluginContextValue = { + config: mockConfig, + core: mockCore, + packageInfo: { version: '0' }, + plugins: {} +}; + +export function MockApmPluginContextWrapper({ + children, + value = {} as ApmPluginContextValue +}: { + children?: React.ReactNode; + value?: ApmPluginContextValue; +}) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } + return ( + + {children} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx similarity index 89% rename from x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx rename to x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx index 7a9aaa6dfb920..d8934ba4b0151 100644 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx @@ -6,7 +6,7 @@ import { createContext } from 'react'; import { AppMountContext, PackageInfo } from 'kibana/public'; -import { ApmPluginSetupDeps, ConfigSchema } from '../new-platform/plugin'; +import { ApmPluginSetupDeps, ConfigSchema } from '../../new-platform/plugin'; export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; diff --git a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx b/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx index 30e6e02c9fbc1..1e9c20494b42e 100644 --- a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx @@ -11,10 +11,8 @@ import { withRouter } from 'react-router-dom'; const initialLocation = {} as Location; const LocationContext = createContext(initialLocation); -const LocationProvider: React.ComponentClass<{}> = withRouter( - ({ location, children }) => { - return ; - } -); +const LocationProvider = withRouter(({ location, children }) => { + return ; +}); export { LocationContext, LocationProvider }; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx new file mode 100644 index 0000000000000..46f51da49692a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.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 { IUrlParams } from './types'; +import { UrlParamsContext, useUiFilters } from '.'; + +const defaultUrlParams = { + page: 0, + serviceName: 'opbeans-python', + transactionType: 'request', + start: '2018-01-10T09:51:41.050Z', + end: '2018-01-10T10:06:41.050Z' +}; + +interface Props { + params?: IUrlParams; + children: React.ReactNode; + refreshTimeRange?: (time: any) => void; +} + +export const MockUrlParamsContextProvider = ({ + params, + children, + refreshTimeRange = () => undefined +}: Props) => { + const urlParams = { ...defaultUrlParams, ...params }; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx index 8d8716e6e5cd7..8918d992b4f53 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx @@ -6,8 +6,9 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx index e3ef1d44c8b03..deb805c542b1e 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx @@ -5,8 +5,9 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; // Wrap the hook with a provider so it can useApmPluginContext const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index c2530d6982c3b..95cebd6b2a465 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-console */ + import React, { useContext, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; @@ -20,7 +22,7 @@ export enum FETCH_STATUS { PENDING = 'pending' } -interface Result { +export interface FetcherResult { data?: Data; status: FETCH_STATUS; error?: Error; @@ -40,13 +42,15 @@ export function useFetcher( options: { preservePreviousData?: boolean; } = {} -): Result> & { refetch: () => void } { +): FetcherResult> & { refetch: () => void } { const { notifications } = useApmPluginContext().core; const { preservePreviousData = true } = options; const { setIsLoading } = useLoadingIndicator(); const { dispatchStatus } = useContext(LoadingIndicatorContext); - const [result, setResult] = useState>>({ + const [result, setResult] = useState< + FetcherResult> + >({ data: undefined, status: FETCH_STATUS.PENDING }); @@ -80,11 +84,27 @@ export function useFetcher( data, status: FETCH_STATUS.SUCCESS, error: undefined - } as Result>); + } as FetcherResult>); } } catch (e) { - const err = e as IHttpFetchError; + const err = e as Error | IHttpFetchError; + if (!didCancel) { + const errorDetails = + 'response' in err ? ( + <> + {err.response?.statusText} ({err.response?.status}) +
+ {i18n.translate('xpack.apm.fetcher.error.url', { + defaultMessage: `URL` + })} +
+ {err.response?.url} + + ) : ( + err.message + ); + notifications.toasts.addWarning({ title: i18n.translate('xpack.apm.fetcher.error.title', { defaultMessage: `Error while fetching resource` @@ -96,13 +116,8 @@ export function useFetcher( defaultMessage: `Error` })} - {err.response?.statusText} ({err.response?.status}) -
- {i18n.translate('xpack.apm.fetcher.error.url', { - defaultMessage: `URL` - })} -
- {err.response?.url} + + {errorDetails}
) }); diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 0103dd72a3fea..e30bed1810c1d 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -10,6 +10,8 @@ import { Route, Router, Switch } from 'react-router-dom'; import { ApmRoute } from '@elastic/apm-rum-react'; import styled from 'styled-components'; import { metadata } from 'ui/metadata'; +import { i18n } from '@kbn/i18n'; +import { AlertType } from '../../../../../plugins/apm/common/alert_types'; import { CoreSetup, CoreStart, @@ -39,6 +41,12 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { + TriggersAndActionsUIPublicPluginSetup, + AlertsContextProvider +} from '../../../../../plugins/triggers_actions_ui/public'; +import { ErrorRateAlertTrigger } from '../components/shared/ErrorRateAlertTrigger'; +import { TransactionDurationAlertTrigger } from '../components/shared/TransactionDurationAlertTrigger'; import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -72,6 +80,7 @@ export interface ApmPluginSetupDeps { data: DataPublicPluginSetup; home: HomePublicPluginSetup; licensing: LicensingPluginSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface ConfigSchema { @@ -135,25 +144,59 @@ export class ApmPlugin plugins }; + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.ErrorRate, + name: i18n.translate('xpack.apm.alertTypes.errorRate', { + defaultMessage: 'Error rate' + }), + iconClass: 'bell', + alertParamsExpression: ErrorRateAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration' + }), + iconClass: 'bell', + alertParamsExpression: TransactionDurationAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + ReactDOM.render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + , document.getElementById(REACT_APP_ROOT_ID) ); diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index 6bcfbc4541b64..36c0e18777bfd 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -11,7 +11,7 @@ import enzymeToJson from 'enzyme-to-json'; import { Location } from 'history'; import moment from 'moment'; import { Moment } from 'moment-timezone'; -import React, { ReactNode } from 'react'; +import React from 'react'; import { render, waitForElement } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router-dom'; @@ -24,12 +24,7 @@ import { ESSearchResponse, ESSearchRequest } from '../../../../../plugins/apm/typings/elasticsearch'; -import { - ApmPluginContext, - ApmPluginContextValue -} from '../context/ApmPluginContext'; -import { ConfigSchema } from '../new-platform/plugin'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -186,57 +181,3 @@ export async function inspectSearchParams( } export type SearchParamsMock = PromiseReturnType; - -const mockCore = { - chrome: { - setBreadcrumbs: () => {} - }, - http: { - basePath: { - prepend: (path: string) => `/basepath${path}` - } - }, - notifications: { - toasts: { - addWarning: () => {}, - addDanger: () => {} - } - } -}; - -const mockConfig: ConfigSchema = { - indexPatternTitle: 'apm-*', - serviceMapEnabled: true, - ui: { - enabled: false - } -}; - -export const mockApmPluginContextValue = { - config: mockConfig, - core: mockCore, - packageInfo: { version: '0' }, - plugins: {} -}; - -export function MockApmPluginContextWrapper({ - children, - value = {} as ApmPluginContextValue -}: { - children?: ReactNode; - value?: ApmPluginContextValue; -}) { - if (value.core?.http) { - createCallApmApi(value.core?.http); - } - return ( - - {children} - - ); -} diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore new file mode 100644 index 0000000000000..8ee01d321b721 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/.gitignore @@ -0,0 +1 @@ +yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json new file mode 100644 index 0000000000000..9121449c53619 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "apm-scripts", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@octokit/rest": "^16.35.0", + "console-stamp": "^0.2.9" + } +} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 825c1a526fcc5..61ba2fdc7f7e3 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,6 +16,7 @@ ******************************/ // compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js new file mode 100644 index 0000000000000..a99651c62dd7a --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.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. + */ + +// compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.ts'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator' + ], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }] + ] +}); + +require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts new file mode 100644 index 0000000000000..dfed9223ef708 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.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. + */ + +// @ts-ignore +import { Octokit } from '@octokit/rest'; + +export async function downloadTelemetryTemplate(octokit: Octokit) { + const file = await octokit.repos.getContents({ + owner: 'elastic', + repo: 'telemetry', + path: 'config/templates/xpack-phone-home.json', + // @ts-ignore + mediaType: { + format: 'application/vnd.github.VERSION.raw' + } + }); + + if (Array.isArray(file.data)) { + throw new Error('Expected single response, got array'); + } + + return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts new file mode 100644 index 0000000000000..8d76063a7fdf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DeepPartial } from 'utility-types'; +import { + merge, + omit, + defaultsDeep, + range, + mapValues, + isPlainObject, + flatten +} from 'lodash'; +import uuid from 'uuid'; +import { + CollectTelemetryParams, + collectDataTelemetry + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; + +interface GenerateOptions { + days: number; + instances: number; + variation: { + min: number; + max: number; + }; +} + +const randomize = ( + value: unknown, + instanceVariation: number, + dailyGrowth: number +) => { + if (typeof value === 'boolean') { + return Math.random() > 0.5; + } + if (typeof value === 'number') { + return Math.round(instanceVariation * dailyGrowth * value); + } + return value; +}; + +const mapValuesDeep = ( + obj: Record, + iterator: (value: unknown, key: string, obj: Record) => unknown +): Record => + mapValues(obj, (val, key) => + isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) + ); + +export async function generateSampleDocuments( + options: DeepPartial & { + collectTelemetryParams: CollectTelemetryParams; + } +) { + const { collectTelemetryParams, ...preferredOptions } = options; + + const opts: GenerateOptions = defaultsDeep( + { + days: 100, + instances: 50, + variation: { + min: 0.1, + max: 4 + } + }, + preferredOptions + ); + + const sample = await collectDataTelemetry(collectTelemetryParams); + + console.log('Collected telemetry'); // eslint-disable-line no-console + console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console + + const dateOfScriptExecution = new Date(); + + return flatten( + range(0, opts.instances).map(instanceNo => { + const instanceId = uuid.v4(); + const defaults = { + cluster_uuid: instanceId, + stack_stats: { + kibana: { + versions: { + version: '8.0.0' + } + } + } + }; + + const instanceVariation = + Math.random() * (opts.variation.max - opts.variation.min) + + opts.variation.min; + + return range(0, opts.days).map(dayNo => { + const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); + + const timestamp = Date.UTC( + dateOfScriptExecution.getFullYear(), + dateOfScriptExecution.getMonth(), + -dayNo + ); + + const generated = mapValuesDeep(omit(sample, 'versions'), value => + randomize(value, instanceVariation, dailyGrowth) + ); + + return merge({}, defaults, { + timestamp, + stack_stats: { + kibana: { + plugins: { + apm: merge({}, sample, generated) + } + } + } + }); + }); + }) + ); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts new file mode 100644 index 0000000000000..bdc57eac412fc --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This script downloads the telemetry mapping, runs the APM telemetry tasks, +// generates a bunch of randomized data based on the downloaded sample, +// and uploads it to a cluster of your choosing in the same format as it is +// stored in the telemetry cluster. Its purpose is twofold: +// - Easier testing of the telemetry tasks +// - Validate whether we can run the queries we want to on the telemetry data + +import fs from 'fs'; +import path from 'path'; +// @ts-ignore +import { Octokit } from '@octokit/rest'; +import { merge, chunk, flatten, pick, identity } from 'lodash'; +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Client } from 'elasticsearch'; +import { argv } from 'yargs'; +import { promisify } from 'util'; +import { Logger } from 'kibana/server'; +// @ts-ignore +import consoleStamp from 'console-stamp'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; +import { downloadTelemetryTemplate } from './download-telemetry-template'; +import mapping from '../../mappings.json'; +import { generateSampleDocuments } from './generate-sample-documents'; + +consoleStamp(console, '[HH:MM:ss.l]'); + +const githubToken = process.env.GITHUB_TOKEN; + +if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); +} + +const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); +const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); +const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + +const xpackTelemetryIndexName = 'xpack-phone-home'; + +const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) +) || {}) as {}; + +const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST + }, + identity +) as { + 'elasticsearch.username': string; + 'elasticsearch.password': string; + 'elasticsearch.hosts': string; +}; + +const config = { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials +}; + +async function uploadData() { + const octokit = new Octokit({ + auth: githubToken + }); + + const telemetryTemplate = await downloadTelemetryTemplate(octokit); + + const kibanaMapping = mapping['apm-telemetry']; + + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'] + } + : null; + + const client = new Client({ + host: config['elasticsearch.hosts'], + ...(httpAuth + ? { + httpAuth: `${httpAuth.username}:${httpAuth.password}` + } + : {}) + }); + + if (argv.clear) { + try { + await promisify(client.indices.delete.bind(client))({ + index: xpackTelemetryIndexName + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.status !== 404) { + throw err; + } + } + } + + const axiosInstance = axios.create({ + baseURL: config['elasticsearch.hosts'], + ...(httpAuth ? { auth: httpAuth } : {}) + }); + + const newTemplate = merge(telemetryTemplate, { + settings: { + index: { mapping: { total_fields: { limit: 10000 } } } + } + }); + + // override apm mapping instead of merging + newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; + + await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + + const sampleDocuments = await generateSampleDocuments({ + collectTelemetryParams: { + logger: (console as unknown) as Logger, + indices: { + ...config, + apmCustomLinkIndex: '.apm-custom-links', + apmAgentConfigurationIndex: '.apm-agent-configuration' + }, + search: body => { + return promisify(client.search.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + indicesStats: body => { + return promisify(client.indices.stats.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + transportRequest: (params => { + return axiosInstance[params.method](params.path); + }) as CollectTelemetryParams['transportRequest'] + } + }); + + const chunks = chunk(sampleDocuments, 250); + + await chunks.reduce>((prev, documents) => { + return prev.then(async () => { + const body = flatten( + documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) + ); + + return promisify(client.bulk.bind(client))({ + body, + refresh: true + }).then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error(`Failed to upload documents: ${firstError.reason} `); + } + }); + }); + }, Promise.resolve()); +} + +uploadData() + .catch(e => { + if ('response' in e) { + if (typeof e.response === 'string') { + // eslint-disable-next-line no-console + console.log(e.response); + } else { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + e.response, + ['status', 'statusText', 'headers', 'data'], + 2 + ) + ); + } + } else { + // eslint-disable-next-line no-console + console.log(e); + } + process.exit(1); + }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Finished uploading generated telemetry data'); + }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 9c3e80bc22af1..754a113b87554 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; -import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; const filterContext = { and: [ @@ -24,20 +24,21 @@ describe('savedVisualization', () => { const fn = savedVisualization().fn; const args = { id: 'some-id', + timerange: null, + colors: null, + hideLegend: null, }; it('accepts null context', () => { const expression = fn(null, args, {} as any); expect(expression.input.filters).toEqual([]); - expect(expression.input.timeRange).toBeUndefined(); }); it('accepts filter context', () => { const expression = fn(filterContext, args, {} as any); - const embeddableFilters = buildEmbeddableFilters(filterContext.and); + const embeddableFilters = getQueryFilters(filterContext.and); - expect(expression.input.filters).toEqual(embeddableFilters.filters); - expect(expression.input.timeRange).toEqual(embeddableFilters.timeRange); + expect(expression.input.filters).toEqual(embeddableFilters); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index 5b612b7cbd666..9777eaebb36ed 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -11,16 +11,24 @@ import { EmbeddableExpressionType, EmbeddableExpression, } from '../../expression_types'; -import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { id: string; + timerange: TimeRangeArg | null; + colors: SeriesStyle[] | null; + hideLegend: boolean | null; } type Output = EmbeddableExpression; +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + export function savedVisualization(): ExpressionFunctionDefinition< 'savedVisualization', Filter | null, @@ -37,17 +45,51 @@ export function savedVisualization(): ExpressionFunctionDefinition< required: false, help: argHelp.id, }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + colors: { + types: ['seriesStyle'], + help: argHelp.colors, + multi: true, + required: false, + }, + hideLegend: { + types: ['boolean'], + help: argHelp.hideLegend, + required: false, + }, }, type: EmbeddableExpressionType, - fn: (input, { id }) => { + fn: (input, { id, timerange, colors, hideLegend }) => { const filters = input ? input.and : []; + const visOptions: VisualizeInput['vis'] = {}; + + if (colors) { + visOptions.colors = colors.reduce((reduction, color) => { + if (color.label && color.color) { + reduction[color.label] = color.color; + } + return reduction; + }, {} as Record); + } + + if (hideLegend === true) { + // @ts-ignore LegendOpen missing on VisualizeInput + visOptions.legendOpen = false; + } + return { type: EmbeddableExpressionType, input: { id, disableTriggers: true, - ...buildEmbeddableFilters(filters), + timeRange: timerange || defaultTimeRange, + filters: getQueryFilters(filters), + vis: visOptions, }, embeddableType: EmbeddableTypes.visualization, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index d91e70e43bfd5..3cdb6eb460224 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -82,8 +82,13 @@ const embeddable = () => ({ ReactDOM.unmountComponentAtNode(domNode); const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { - handlers.onEmbeddableInputChange(embeddableInputToExpression(updatedInput, embeddableType)); + const updatedExpression = embeddableInputToExpression(updatedInput, embeddableType); + + if (updatedExpression) { + handlers.onEmbeddableInputChange(updatedExpression); + } }); + ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); handlers.onResize(() => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 4c622b0c247fa..9dee40c0f683b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -4,119 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('ui/new_platform'); -import { embeddableInputToExpression } from './embeddable_input_to_expression'; -import { SavedMapInput } from '../../functions/common/saved_map'; -import { SavedLensInput } from '../../functions/common/saved_lens'; -import { EmbeddableTypes } from '../../expression_types'; -import { fromExpression, Ast } from '@kbn/interpreter/common'; +import { + embeddableInputToExpression, + inputToExpressionTypeMap, +} from './embeddable_input_to_expression'; -const baseEmbeddableInput = { +const input = { id: 'embeddableId', filters: [], -}; - -const baseSavedMapInput = { - ...baseEmbeddableInput, - isLayerTOCOpen: false, - refreshConfig: { - isPaused: true, - interval: 0, - }, hideFilterActions: true as true, }; describe('input to expression', () => { - describe('Map Embeddable', () => { - it('converts to a savedMap expression', () => { - const input: SavedMapInput = { - ...baseSavedMapInput, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); - - expect(ast.type).toBe('expression'); - expect(ast.chain[0].function).toBe('savedMap'); - - expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); - - expect(ast.chain[0].arguments).not.toHaveProperty('title'); - expect(ast.chain[0].arguments).not.toHaveProperty('center'); - expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); - }); - - it('includes optional input values', () => { - const input: SavedMapInput = { - ...baseSavedMapInput, - mapCenter: { - lat: 1, - lon: 2, - zoom: 3, - }, - title: 'title', - timeRange: { - from: 'now-1h', - to: 'now', - }, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); - - const centerExpression = ast.chain[0].arguments.center[0] as Ast; - - expect(centerExpression.chain[0].function).toBe('mapCenter'); - expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); - expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); - expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); - - const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; - - expect(timerangeExpression.chain[0].function).toBe('timerange'); - expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); - expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); - }); - }); - - describe('Lens Embeddable', () => { - it('converts to a savedLens expression', () => { - const input: SavedLensInput = { - ...baseEmbeddableInput, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); - const ast = fromExpression(expression); - - expect(ast.type).toBe('expression'); - expect(ast.chain[0].function).toBe('savedLens'); - - expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); - - expect(ast.chain[0].arguments).not.toHaveProperty('title'); - expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); - }); - - it('includes optional input values', () => { - const input: SavedLensInput = { - ...baseEmbeddableInput, - title: 'title', - timeRange: { - from: 'now-1h', - to: 'now', - }, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); + it('converts to expression if method is available', () => { + const newType = 'newType'; + const mockReturn = 'expression'; + inputToExpressionTypeMap[newType] = jest.fn().mockReturnValue(mockReturn); - expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); - expect(ast.chain[0].arguments).toHaveProperty('timerange'); + const expression = embeddableInputToExpression(input, newType); - const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; - expect(timerangeExpression.chain[0].function).toBe('timerange'); - expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); - expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); - }); + expect(expression).toBe(mockReturn); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index 6428507b16a0c..5cba012fcb8e3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -5,8 +5,15 @@ */ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; -import { SavedMapInput } from '../../functions/common/saved_map'; -import { SavedLensInput } from '../../functions/common/saved_lens'; +import { toExpression as mapToExpression } from './input_type_to_expression/map'; +import { toExpression as visualizationToExpression } from './input_type_to_expression/visualization'; +import { toExpression as lensToExpression } from './input_type_to_expression/lens'; + +export const inputToExpressionTypeMap = { + [EmbeddableTypes.map]: mapToExpression, + [EmbeddableTypes.visualization]: visualizationToExpression, + [EmbeddableTypes.lens]: lensToExpression, +}; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -14,56 +21,8 @@ import { SavedLensInput } from '../../functions/common/saved_lens'; export function embeddableInputToExpression( input: EmbeddableInput, embeddableType: string -): string { - const expressionParts: string[] = []; - - if (embeddableType === EmbeddableTypes.map) { - const mapInput = input as SavedMapInput; - - expressionParts.push('savedMap'); - - expressionParts.push(`id="${input.id}"`); - - if (input.title) { - expressionParts.push(`title="${input.title}"`); - } - - if (mapInput.mapCenter) { - expressionParts.push( - `center={mapCenter lat=${mapInput.mapCenter.lat} lon=${mapInput.mapCenter.lon} zoom=${mapInput.mapCenter.zoom}}` - ); - } - - if (mapInput.timeRange) { - expressionParts.push( - `timerange={timerange from="${mapInput.timeRange.from}" to="${mapInput.timeRange.to}"}` - ); - } - - if (mapInput.hiddenLayers && mapInput.hiddenLayers.length) { - for (const layerId of mapInput.hiddenLayers) { - expressionParts.push(`hideLayer="${layerId}"`); - } - } +): string | undefined { + if (inputToExpressionTypeMap[embeddableType]) { + return inputToExpressionTypeMap[embeddableType](input as any); } - - if (embeddableType === EmbeddableTypes.lens) { - const lensInput = input as SavedLensInput; - - expressionParts.push('savedLens'); - - expressionParts.push(`id="${input.id}"`); - - if (input.title) { - expressionParts.push(`title="${input.title}"`); - } - - if (lensInput.timeRange) { - expressionParts.push( - `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` - ); - } - } - - return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts new file mode 100644 index 0000000000000..c4a9a22be3202 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.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. + */ + +import { toExpression } from './lens'; +import { SavedLensInput } from '../../../functions/common/saved_lens'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseEmbeddableInput = { + id: 'embeddableId', + filters: [], +}; + +describe('toExpression', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts new file mode 100644 index 0000000000000..445cb7480ff80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.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 { SavedLensInput } from '../../../functions/common/saved_lens'; + +export function toExpression(input: SavedLensInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts new file mode 100644 index 0000000000000..4c294fb37c2db --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toExpression } from './map'; +import { SavedMapInput } from '../../../functions/common/saved_map'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseSavedMapInput = { + id: 'embeddableId', + filters: [], + isLayerTOCOpen: false, + refreshConfig: { + isPaused: true, + interval: 0, + }, + hideFilterActions: true as true, +}; + +describe('toExpression', () => { + it('converts to a savedMap expression', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedMap'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('center'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + mapCenter: { + lat: 1, + lon: 2, + zoom: 3, + }, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const centerExpression = ast.chain[0].arguments.center[0] as Ast; + + expect(centerExpression.chain[0].function).toBe('mapCenter'); + expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); + expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); + expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts new file mode 100644 index 0000000000000..e3f9eca61ae28 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/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. + */ + +import { SavedMapInput } from '../../../functions/common/saved_map'; + +export function toExpression(input: SavedMapInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedMap'); + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (input.mapCenter) { + expressionParts.push( + `center={mapCenter lat=${input.mapCenter.lat} lon=${input.mapCenter.lon} zoom=${input.mapCenter.zoom}}` + ); + } + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + if (input.hiddenLayers && input.hiddenLayers.length) { + for (const layerId of input.hiddenLayers) { + expressionParts.push(`hideLayer="${layerId}"`); + } + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts new file mode 100644 index 0000000000000..306020293abe6 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toExpression } from './visualization'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseInput = { + id: 'embeddableId', +}; + +describe('toExpression', () => { + it('converts to a savedVisualization expression', () => { + const input = { + ...baseInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedVisualization'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + }); + + it('includes timerange if given', () => { + const input = { + ...baseInput, + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + + it('includes colors if given', () => { + const colorMap = { a: 'red', b: 'blue' }; + + const input = { + ...baseInput, + vis: { + colors: { + a: 'red', + b: 'blue', + }, + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const colors = ast.chain[0].arguments.colors as Ast[]; + + const aColor = colors.find(color => color.chain[0].arguments.label[0] === 'a'); + const bColor = colors.find(color => color.chain[0].arguments.label[0] === 'b'); + + expect(aColor?.chain[0].arguments.color[0]).toBe(colorMap.a); + expect(bColor?.chain[0].arguments.color[0]).toBe(colorMap.b); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts new file mode 100644 index 0000000000000..be0dd6a79292f --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { VisualizeInput } from 'src/legacy/core_plugins/visualizations/public'; + +export function toExpression(input: VisualizeInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedVisualization'); + expressionParts.push(`id="${input.id}"`); + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + if (input.vis?.colors) { + Object.entries(input.vis.colors) + .map(([label, color]) => { + return `colors={seriesStyle label="${label}" color="${color}"}`; + }) + .reduce((_, part) => expressionParts.push(part), 0); + } + + // @ts-ignore LegendOpen missing on VisualizeInput type + if (input.vis?.legendOpen !== undefined && input.vis.legendOpen === false) { + expressionParts.push(`hideLegend=true`); + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js index c1bfd7c99ac41..126699534caad 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js @@ -7,7 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { RendererStrings } from '../../../i18n'; -import { Markdown } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { Markdown } from '../../../../../../../src/plugins/kibana_react/public'; const { markdown: strings } = RendererStrings; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts index e3b412284442d..21a2e1c1b8800 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts @@ -14,6 +14,20 @@ export const help: FunctionHelp> = { defaultMessage: `Returns an embeddable for a saved visualization object`, }), args: { - id: 'The id of the saved visualization object', + id: i18n.translate('xpack.canvas.functions.savedVisualization.args.idHelpText', { + defaultMessage: `The ID of the Saved Visualization Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedVisualization.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + colors: i18n.translate('xpack.canvas.functions.savedVisualization.args.colorsHelpText', { + defaultMessage: `Define the color to use for a specific series`, + }), + hideLegend: i18n.translate( + 'xpack.canvas.functions.savedVisualization.args.hideLegendHelpText', + { + defaultMessage: `Should the legend be hidden`, + } + ), }, }; diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 353a59397d6b6..a86784d374f49 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -24,10 +24,10 @@ const allowedEmbeddables = { [EmbeddableTypes.lens]: (id: string) => { return `savedLens id="${id}" | render`; }, - // FIX: Only currently allow Map embeddables - /* [EmbeddableTypes.visualization]: (id: string) => { - return `filters | savedVisualization id="${id}" | render`; + [EmbeddableTypes.visualization]: (id: string) => { + return `savedVisualization id="${id}" | render`; }, + /* [EmbeddableTypes.search]: (id: string) => { return `filters | savedSearch id="${id}" | render`; },*/ diff --git a/x-pack/legacy/plugins/graph/index.ts b/x-pack/legacy/plugins/graph/index.ts index 5122796335e45..53d32a836cfa1 100644 --- a/x-pack/legacy/plugins/graph/index.ts +++ b/x-pack/legacy/plugins/graph/index.ts @@ -37,6 +37,7 @@ export const graph: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', }), + order: 1200, icon: 'graphApp', navLinkId: 'graph', app: ['graph', 'kibana'], @@ -44,6 +45,8 @@ export const graph: LegacyPluginInitializer = kibana => { validLicenses: ['platinum', 'enterprise', 'trial'], privileges: { all: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: ['graph-workspace'], read: ['index-pattern'], @@ -51,6 +54,8 @@ export const graph: LegacyPluginInitializer = kibana => { ui: ['save', 'delete'], }, read: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: [], read: ['index-pattern', 'graph-workspace'], diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index fbda18cc0e307..be72dd4b4edef 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -77,6 +77,20 @@ function createMockFilterManager() { }; } +function createMockTimefilter() { + const unsubscribe = jest.fn(); + + return { + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + setTime: jest.fn(), + getTimeUpdate$: () => ({ + subscribe: ({ next }: { next: () => void }) => { + return unsubscribe; + }, + }), + }; +} + describe('Lens App', () => { let frame: jest.Mocked; let core: ReturnType; @@ -108,10 +122,7 @@ describe('Lens App', () => { query: { filterManager: createMockFilterManager(), timefilter: { - timefilter: { - getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), - setTime: jest.fn(), - }, + timefilter: createMockTimefilter(), }, }, indexPatterns: { diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index a0c6e4c21a34b..dfea2e39fcbc5 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -94,8 +94,23 @@ export function App({ trackUiEvent('app_filters_updated'); }, }); + + const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ + next: () => { + const currentRange = data.query.timefilter.timefilter.getTime(); + setState(s => ({ + ...s, + dateRange: { + fromDate: currentRange.from, + toDate: currentRange.to, + }, + })); + }, + }); + return () => { filterSubscription.unsubscribe(); + timeSubscription.unsubscribe(); }; }, []); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 252ba5c9bc0bc..d18174baacdb9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -14,8 +14,11 @@ import { IIndexPattern, TimefilterContract, } from 'src/plugins/data/public'; + import { Subscription } from 'rxjs'; import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/events'; + import { Embeddable as AbstractEmbeddable, EmbeddableOutput, @@ -90,6 +93,18 @@ export class Embeddable extends AbstractEmbeddable !filter.meta.disabled) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 4e48d0c0987b5..e36622f876acd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -780,7 +780,7 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table.columns[0].operation.isBucketed).toBeFalsy(); }); - it('prepends a terms column on string field', () => { + it('appends a terms column on string field', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'dest', @@ -795,7 +795,7 @@ describe('IndexPattern Data Source suggestions', () => { layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['id1', 'cola', 'colb'], + columnOrder: ['cola', 'id1', 'colb'], columns: { ...initialState.layers.currentLayer.columns, id1: expect.objectContaining({ @@ -810,7 +810,7 @@ describe('IndexPattern Data Source suggestions', () => { ); }); - it('appends a metric column on a number field', () => { + it('replaces a metric column on a number field if only one other metric is already set', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'memory', @@ -819,15 +819,57 @@ describe('IndexPattern Data Source suggestions', () => { searchable: true, }); + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: expect.objectContaining({ + currentLayer: expect.objectContaining({ + columnOrder: ['cola', 'id1'], + columns: { + cola: initialState.layers.currentLayer.columns.cola, + id1: expect.objectContaining({ + operationType: 'avg', + sourceField: 'memory', + }), + }, + }), + }), + }), + }) + ); + }); + + it('adds a metric column on a number field if no other metrics set', () => { + const initialState = stateWithNonEmptyTables(); + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + ...initialState.layers, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + cola: initialState.layers.currentLayer.columns.cola, + }, + columnOrder: ['cola'], + }, + }, + }; + const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }); + expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { - previousLayer: initialState.layers.previousLayer, + previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'colb', 'id1'], + columnOrder: ['cola', 'id1'], columns: { - ...initialState.layers.currentLayer.columns, + ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', @@ -840,10 +882,30 @@ describe('IndexPattern Data Source suggestions', () => { ); }); - it('appends a metric column with a different operation on a number field if field is already in use', () => { + it('adds a metric column on a number field if 2 or more other metric', () => { const initialState = stateWithNonEmptyTables(); - const suggestions = getDatasourceSuggestionsForField(initialState, '1', { - name: 'bytes', + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + ...initialState.layers, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + ...initialState.layers.currentLayer.columns, + colc: { + dataType: 'number', + isBucketed: false, + sourceField: 'dest', + label: 'Unique count of dest', + operationType: 'cardinality', + }, + }, + columnOrder: ['cola', 'colb', 'colc'], + }, + }, + }; + const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { + name: 'memory', type: 'number', aggregatable: true, searchable: true, @@ -853,14 +915,14 @@ describe('IndexPattern Data Source suggestions', () => { expect.objectContaining({ state: expect.objectContaining({ layers: { - previousLayer: initialState.layers.previousLayer, + previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'colb', 'id1'], + columnOrder: ['cola', 'colb', 'colc', 'id1'], columns: { - ...initialState.layers.currentLayer.columns, + ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ - operationType: 'sum', - sourceField: 'bytes', + operationType: 'avg', + sourceField: 'memory', }), }, }), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 35e99fc4fe98d..96127caa67bb4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -197,16 +197,29 @@ function addFieldAsMetricOperation( field, }); const newColumnId = generateId(); - const updatedColumns = { - ...layer.columns, - [newColumnId]: newColumn, - }; - const updatedColumnOrder = [...layer.columnOrder, newColumnId]; + + const [, metrics] = separateBucketColumns(layer); + + // Add metrics if there are 0 or > 1 metric + if (metrics.length !== 1) { + return { + indexPatternId: indexPattern.id, + columns: { + ...layer.columns, + [newColumnId]: newColumn, + }, + columnOrder: [...layer.columnOrder, newColumnId], + }; + } + + // If only one metric, replace instead of add + const newColumns = { ...layer.columns, [newColumnId]: newColumn }; + delete newColumns[metrics[0]]; return { indexPatternId: indexPattern.id, - columns: updatedColumns, - columnOrder: updatedColumnOrder, + columns: newColumns, + columnOrder: [...layer.columnOrder.filter(c => c !== metrics[0]), newColumnId], }; } @@ -231,21 +244,34 @@ function addFieldAsBucketOperation( ...layer.columns, [newColumnId]: newColumn, }; + + const oldDateHistogramIndex = layer.columnOrder.findIndex( + columnId => layer.columns[columnId].operationType === 'date_histogram' + ); + const oldDateHistogramId = + oldDateHistogramIndex > -1 ? layer.columnOrder[oldDateHistogramIndex] : null; + let updatedColumnOrder: string[] = []; - if (applicableBucketOperation === 'terms') { - updatedColumnOrder = [newColumnId, ...buckets, ...metrics]; - } else { - const oldDateHistogramColumn = layer.columnOrder.find( - columnId => layer.columns[columnId].operationType === 'date_histogram' - ); - if (oldDateHistogramColumn) { - delete updatedColumns[oldDateHistogramColumn]; + if (oldDateHistogramId) { + if (applicableBucketOperation === 'terms') { + // Insert the new terms bucket above the first date histogram + updatedColumnOrder = [ + ...buckets.slice(0, oldDateHistogramIndex), + newColumnId, + ...buckets.slice(oldDateHistogramIndex, buckets.length), + ...metrics, + ]; + } else if (applicableBucketOperation === 'date_histogram') { + // Replace date histogram with new date histogram + delete updatedColumns[oldDateHistogramId]; updatedColumnOrder = layer.columnOrder.map(columnId => - columnId !== oldDateHistogramColumn ? columnId : newColumnId + columnId !== oldDateHistogramId ? columnId : newColumnId ); - } else { - updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } + } else { + // Insert the new bucket after existing buckets. Users will see the same data + // they already had, with an extra level of detail. + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } return { indexPatternId: indexPattern.id, diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts index 276f24433c670..62f47a21c85b0 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts @@ -72,6 +72,46 @@ describe('metric_visualization', () => { }); }); + describe('#getConfiguration', () => { + it('can add a metric when there is no accessor', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: undefined, + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: true, + }), + ], + }); + }); + + it('is not allowed to add a metric once one accessor is set', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: 'a', + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: false, + }), + ], + }); + }); + }); + describe('#setDimension', () => { it('sets the accessor', () => { expect( diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx index 44256df5aed6d..73b8019a31eaa 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx @@ -94,7 +94,7 @@ export const metricVisualization: Visualization = { groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }), layerId: props.state.layerId, accessors: props.state.accessor ? [props.state.accessor] : [], - supportsMoreColumns: false, + supportsMoreColumns: !props.state.accessor, filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', }, ], diff --git a/x-pack/legacy/plugins/lens/public/plugin.tsx b/x-pack/legacy/plugins/lens/public/plugin.tsx index c74653c70703c..16f1d194b240a 100644 --- a/x-pack/legacy/plugins/lens/public/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/plugin.tsx @@ -28,6 +28,8 @@ import { stopReportManager, trackUiEvent, } from './lens_ui_telemetry'; + +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { KibanaLegacySetup } from '../../../../../src/plugins/kibana_legacy/public'; import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../../../plugins/lens/common'; import { @@ -40,7 +42,6 @@ import { EmbeddableSetup, EmbeddableStart } from '../../../../../src/plugins/emb import { EditorFrameStart } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { VisualizationsSetup } from './legacy_imports'; - export interface LensPluginSetupDependencies { kibanaLegacy: KibanaLegacySetup; expressions: ExpressionsSetup; @@ -56,6 +57,7 @@ export interface LensPluginStartDependencies { data: DataPublicPluginStart; embeddable: EmbeddableStart; expressions: ExpressionsStart; + uiActions: UiActionsStart; } export const isRisonObject = (value: RisonValue): value is RisonObject => { @@ -217,6 +219,7 @@ export class LensPlugin { start(core: CoreStart, startDependencies: LensPluginStartDependencies) { this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; + this.xyVisualization.start(core, startDependencies); } stop() { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 44398c929a2b9..bef53c2fd266e 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -6,6 +6,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` > ('executeTriggerActions'); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx index 6323a063c988b..d6abee101db31 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -12,6 +12,9 @@ import { LineSeries, Settings, ScaleType, + GeometryValue, + XYChartSeriesIdentifier, + SeriesNameFn, } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { LensMultiTable } from '../types'; @@ -19,6 +22,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; import { createMockExecutionContext } from '../../../../../../src/plugins/expressions/common/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +const executeTriggerActions = jest.fn(); function sampleArgs() { const data: LensMultiTable = { @@ -140,6 +146,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -165,6 +172,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` @@ -194,6 +202,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(Settings).prop('xDomain')).toBeUndefined(); @@ -208,6 +217,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -223,6 +233,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -238,6 +249,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -245,6 +257,69 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('rotation')).toEqual(90); }); + test('onElementClick returns correct context data', () => { + const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1' }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'd', + splitAccessors: {}, + seriesKeys: [2, 'd'], + }; + + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(Settings) + .first() + .prop('onElementClick')!([[geometry, series as XYChartSeriesIdentifier]]); + + expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { + data: { + data: [ + { + column: 1, + row: 1, + table: data.tables.first, + value: 5, + }, + { + column: 1, + row: 0, + table: data.tables.first, + value: 2, + }, + ], + }, + }); + }); + test('it renders stacked bar', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -254,6 +329,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -270,6 +346,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -289,6 +366,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -306,6 +384,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="CEST" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); @@ -322,6 +401,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); @@ -345,6 +425,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); @@ -362,12 +443,13 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); }); - test('it rewrites the rows based on provided labels', () => { + test('it names the series for multiple accessors', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -377,27 +459,60 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); - expect(component.find(LineSeries).prop('data')).toEqual([ - { 'Label A': 1, 'Label B': 2, c: 'I', 'Label D': 'Foo', d: 'Foo' }, - { 'Label A': 1, 'Label B': 5, c: 'J', 'Label D': 'Bar', d: 'Bar' }, - ]); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect( + nameFn( + { + seriesKeys: ['a', 'b', 'c', 'd'], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }, + false + ) + ).toEqual('Label A - Label B - c - Label D'); }); - test('it uses labels as Y accessors', () => { + test('it names the series for a single accessor', () => { const { data, args } = sampleArgs(); const component = shallow( ); - expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect( + nameFn( + { + seriesKeys: ['a', 'b', 'c', 'd'], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }, + false + ) + ).toEqual('Label A'); }); test('it set the scale of the x axis according to the args prop', () => { @@ -410,6 +525,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -425,6 +541,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -440,6 +557,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); @@ -456,6 +574,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); @@ -472,6 +591,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} chartTheme={{}} timeZone="UTC" + executeTriggerActions={executeTriggerActions} /> ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -490,6 +610,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx index cc30e5d18a7f7..98d95c2ea7715 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -15,10 +15,11 @@ import { BarSeries, Position, PartialTheme, + GeometryValue, + XYChartSeriesIdentifier, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { - KibanaDatatable, IInterpreterRenderHandlers, ExpressionRenderDefinition, ExpressionFunctionDefinition, @@ -27,11 +28,20 @@ import { import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { FormatFactory } from '../legacy_imports'; +import { EmbeddableVisTriggerContext } from '../../../../../../src/plugins/embeddable/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/events'; +import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; +import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; +import { getExecuteTriggerActions } from './services'; + +type InferPropType = T extends React.FunctionComponent ? P : T; +type SeriesSpec = InferPropType & + InferPropType & + InferPropType; export interface XYChartProps { data: LensMultiTable; @@ -48,6 +58,7 @@ type XYChartRenderProps = XYChartProps & { chartTheme: PartialTheme; formatFactory: FormatFactory; timeZone: string; + executeTriggerActions: UiActionsStart['executeTriggerActions']; }; export const xyChart: ExpressionFunctionDefinition< @@ -109,10 +120,15 @@ export const getXyChartRenderer = (dependencies: { validate: () => undefined, reuseDomNode: true, render: (domNode: Element, config: XYChartProps, handlers: IInterpreterRenderHandlers) => { + const executeTriggerActions = getExecuteTriggerActions(); handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); ReactDOM.render( - + , domNode, () => handlers.done() @@ -144,7 +160,14 @@ export function XYChartReportable(props: XYChartRenderProps) { ); } -export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYChartRenderProps) { +export function XYChart({ + data, + args, + formatFactory, + timeZone, + chartTheme, + executeTriggerActions, +}: XYChartRenderProps) { const { legend, layers } = args; if (Object.values(data.tables).every(table => table.rows.length === 0)) { @@ -185,7 +208,13 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC const shouldRotate = isHorizontalChart(layers); const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; - + const xDomain = + data.dateRange && layers.every(l => l.xScaleType === 'time') + ? { + min: data.dateRange.fromDate.getTime(), + max: data.dateRange.toDate.getTime(), + } + : undefined; return ( l.xScaleType === 'time') - ? { - min: data.dateRange.fromDate.getTime(), - max: data.dateRange.toDate.getTime(), - } - : undefined - } + xDomain={xDomain} + onElementClick={([[geometry, series]]) => { + // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue + const xySeries = series as XYChartSeriesIdentifier; + const xyGeometry = geometry as GeometryValue; + + const layer = layers.find(l => + xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + if (!layer) { + return; + } + + const table = data.tables[layer.layerId]; + + const points = [ + { + row: table.rows.findIndex( + row => layer.xAccessor && row[layer.xAccessor] === xyGeometry.x + ), + column: table.columns.findIndex(col => col.id === layer.xAccessor), + value: xyGeometry.x, + }, + ]; + + if (xySeries.seriesKeys.length > 1) { + const pointValue = xySeries.seriesKeys[0]; + + points.push({ + row: table.rows.findIndex( + row => layer.splitAccessor && row[layer.splitAccessor] === pointValue + ), + column: table.columns.findIndex(col => col.id === layer.splitAccessor), + value: pointValue, + }); + } + + const xAxisFieldName: string | undefined = table.columns.find( + col => col.id === layer.xAccessor + )?.meta?.aggConfigParams?.field; + + const timeFieldName = xDomain && xAxisFieldName; + + const context: EmbeddableVisTriggerContext = { + data: { + data: points.map(point => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + }, + timeFieldName, + }; + + executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context); + }} /> columnToLabelMap[accessor] || accessor); - const idForLegend = splitAccessorLabel || yAccessors; - const sanitized = sanitizeRows({ - splitAccessor, - formatFactory, - columnToLabelMap, - table: data.tables[layerId], - }); - - const seriesProps = { - key: index, - splitSeriesAccessors: sanitized.splitAccessor ? [sanitized.splitAccessor] : [], + const columnToLabelMap: Record = columnToLabel + ? JSON.parse(columnToLabel) + : {}; + + const table = data.tables[layerId]; + + // For date histogram chart type, we're getting the rows that represent intervals without data. + // To not display them in the legend, they need to be filtered out. + const rows = table.rows.filter( + row => + !(splitAccessor && !row[splitAccessor] && accessors.every(accessor => !row[accessor])) + ); + + const seriesProps: SeriesSpec = { + splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], - id: idForLegend, + id: splitAccessor || accessors.join(','), xAccessor, - yAccessors, - data: sanitized.rows, + yAccessors: accessors, + data: rows, xScaleType, yScaleType, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, + name(d) { + if (accessors.length > 1) { + return d.seriesKeys + .map((key: string | number) => columnToLabelMap[key] || key) + .join(' - '); + } + return columnToLabelMap[d.seriesKeys[0]] ?? d.seriesKeys[0]; + }, }; - return seriesType === 'line' ? ( - - ) : seriesType === 'bar' || - seriesType === 'bar_stacked' || - seriesType === 'bar_horizontal' || - seriesType === 'bar_horizontal_stacked' ? ( - - ) : ( - - ); + switch (seriesType) { + case 'line': + return ; + case 'bar': + case 'bar_stacked': + case 'bar_horizontal': + case 'bar_horizontal_stacked': + return ; + default: + return ; + } } )} ); } - -/** - * Renames the columns to match the user-configured accessors in - * columnToLabelMap. If a splitAccessor is provided, formats the - * values in that column. - */ -function sanitizeRows({ - splitAccessor, - table, - formatFactory, - columnToLabelMap, -}: { - splitAccessor?: string; - table: KibanaDatatable; - formatFactory: FormatFactory; - columnToLabelMap: Record; -}) { - const column = table.columns.find(c => c.id === splitAccessor); - const formatter = formatFactory(column && column.formatHint); - - return { - splitAccessor: column && column.id, - rows: table.rows.map(r => { - const newRow: typeof r = {}; - - if (column) { - newRow[column.id] = formatter.convert(r[column.id]); - } - - Object.keys(r).forEach(key => { - const newKey = columnToLabelMap[key] || key; - newRow[newKey] = r[key]; - }); - return newRow; - }), - }; -} diff --git a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js index 795899ff32f97..97b336ec0728b 100755 --- a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js @@ -6,7 +6,7 @@ import React from 'react'; import { toastNotifications } from 'ui/notify'; -import { MarkdownSimple } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../src/plugins/kibana_react/public'; import { PLUGIN } from '../../../common/constants'; export class LogstashLicenseService { diff --git a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts index 3281fb5892eac..a0102a4249a59 100644 --- a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts +++ b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts @@ -5,25 +5,41 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { Query } from './map_descriptor'; + +type Extent = { + maxLat: number; + maxLon: number; + minLat: number; + minLon: number; +}; + // Global map state passed to every layer. export type MapFilters = { - buffer: unknown; - extent: unknown; + buffer: Extent; // extent with additional buffer + extent: Extent; // map viewport filters: unknown[]; - query: unknown; + query: Query; refreshTimerLastTriggeredAt: string; timeFilters: unknown; zoom: number; }; -export type VectorLayerRequestMeta = MapFilters & { +export type VectorSourceRequestMeta = MapFilters & { applyGlobalQuery: boolean; fieldNames: string[]; geogridPrecision: number; - sourceQuery: unknown; + sourceQuery: Query; sourceMeta: unknown; }; +export type VectorStyleRequestMeta = MapFilters & { + dynamicStyleFields: string[]; + isTimeAware: boolean; + sourceQuery: Query; + timeFilters: unknown; +}; + export type ESSearchSourceResponseMeta = { areResultsTrimmed?: boolean; sourceType?: string; @@ -35,7 +51,9 @@ export type ESSearchSourceResponseMeta = { }; // Partial because objects are justified downstream in constructors -export type DataMeta = Partial & Partial; +export type DataMeta = Partial & + Partial & + Partial; export type DataRequestDescriptor = { dataId: string; diff --git a/x-pack/legacy/plugins/maps/common/map_descriptor.ts b/x-pack/legacy/plugins/maps/common/map_descriptor.ts new file mode 100644 index 0000000000000..570398e37c5d4 --- /dev/null +++ b/x-pack/legacy/plugins/maps/common/map_descriptor.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +export type Query = { + language: string; + query: string; + queryLastTriggeredAt: string; +}; diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts index 418f2880c1077..3a61d5affd861 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { LAYER_TYPE } from '../../common/constants'; import { DataMeta, MapFilters } from '../../common/data_request_descriptor_types'; export type SyncContext = { @@ -16,3 +17,10 @@ export type SyncContext = { registerCancelCallback(requestToken: symbol, callback: () => void): void; dataFilters: MapFilters; }; + +export function updateSourceProp( + layerId: string, + propName: string, + value: unknown, + newLayerType?: LAYER_TYPE +): void; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts new file mode 100644 index 0000000000000..6d1d076c723ad --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { LAYER_TYPE } from '../../../common/constants'; + +export type OnSourceChangeArgs = { + propName: string; + value: unknown; + newLayerType?: LAYER_TYPE; +}; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js index 413d66fce7f70..a2850d2bb6c23 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js @@ -110,7 +110,9 @@ function getImageData(img) { export async function loadSpriteSheetImageData(imgUrl) { return new Promise((resolve, reject) => { const image = new Image(); - image.crossOrigin = 'Anonymous'; + if (isCrossOriginUrl(imgUrl)) { + image.crossOrigin = 'Anonymous'; + } image.onload = el => { const imgData = getImageData(el.currentTarget); resolve(imgData); @@ -142,3 +144,13 @@ export async function addSpritesheetToMap(json, imgUrl, mbMap) { const imgData = await loadSpriteSheetImageData(imgUrl); addSpriteSheetToMapFromImageData(json, imgData, mbMap); } + +function isCrossOriginUrl(url) { + const a = window.document.createElement('a'); + a.href = url; + return ( + a.protocol !== window.document.location.protocol || + a.host !== window.document.location.host || + a.port !== window.document.location.port + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts index 22990538bd5d3..8c54720987e41 100644 --- a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts @@ -23,7 +23,6 @@ import { FIELD_ORIGIN, } from '../../common/constants'; import { ESGeoGridSource } from './sources/es_geo_grid_source/es_geo_grid_source'; -// @ts-ignore import { canSkipSourceUpdate } from './util/can_skip_fetch'; import { IVectorLayer, VectorLayerArguments } from './vector_layer'; import { IESSource } from './sources/es_source'; diff --git a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js index ef78b5afe3a3a..22f7a92c17c51 100644 --- a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js @@ -72,7 +72,7 @@ export class HeatmapLayer extends VectorLayer { const propertyKey = this._getPropKeyOfSelectedMetric(); const dataBoundToMap = AbstractLayer.getBoundDataForSource(mbMap, this.getId()); if (featureCollection !== dataBoundToMap) { - let max = 0; + let max = 1; //max will be at least one, since counts or sums will be at least one. for (let i = 0; i < featureCollection.features.length; i++) { max = Math.max(featureCollection.features[i].properties[propertyKey], max); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap new file mode 100644 index 0000000000000..967225d6f0fdc --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not render clusters option when clustering is not supported 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` + + +
+ +
+
+ + + + + + + + + + + +
+`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap index c94f305773f35..0cb7f67fb9c92 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap @@ -91,253 +91,16 @@ exports[`should enable sort order select when sort field provided 1`] = ` size="s" /> - -
- -
-
- - - - - - - -
- - -`; - -exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` - - - -
- -
-
- - -
- - - -
- -
-
- - - - - - - -
- - - -
- -
-
- - - - - - - - - - - - - -
- -
- -
-
- - - - - - -
{ @@ -34,11 +27,26 @@ function getGeoFields(fields) { ); }); } + +function isGeoFieldAggregatable(indexPattern, geoFieldName) { + if (!indexPattern) { + return false; + } + + const geoField = indexPattern.fields.getByName(geoFieldName); + return geoField && geoField.aggregatable; +} + const RESET_INDEX_PATTERN_STATE = { indexPattern: undefined, - geoField: undefined, + geoFields: undefined, + + // ES search source descriptor state + geoFieldName: undefined, filterByMapBounds: DEFAULT_FILTER_BY_MAP_BOUNDS, - showFilterByBoundsSwitch: false, + scalingType: SCALING_TYPES.CLUSTERS, // turn on clusting by default + topHitsSplitField: undefined, + topHitsSize: 1, }; export class CreateSourceEditor extends Component { @@ -58,41 +66,28 @@ export class CreateSourceEditor extends Component { componentDidMount() { this._isMounted = true; - this.loadIndexPattern(this.state.indexPatternId); } - onIndexPatternSelect = indexPatternId => { + _onIndexPatternSelect = indexPatternId => { this.setState( { indexPatternId, }, - this.loadIndexPattern(indexPatternId) + this._loadIndexPattern(indexPatternId) ); }; - loadIndexPattern = indexPatternId => { + _loadIndexPattern = indexPatternId => { this.setState( { isLoadingIndexPattern: true, ...RESET_INDEX_PATTERN_STATE, }, - this.debouncedLoad.bind(null, indexPatternId) + this._debouncedLoad.bind(null, indexPatternId) ); }; - loadIndexDocCount = async indexPatternTitle => { - const http = getHttp(); - const { count } = await http.fetch(`../${GIS_API_PATH}/indexCount`, { - method: 'GET', - credentials: 'same-origin', - query: { - index: indexPatternTitle, - }, - }); - return count; - }; - - debouncedLoad = _.debounce(async indexPatternId => { + _debouncedLoad = _.debounce(async indexPatternId => { if (!indexPatternId || indexPatternId.length === 0) { return; } @@ -105,15 +100,6 @@ export class CreateSourceEditor extends Component { return; } - let indexHasSmallDocCount = false; - try { - const indexDocCount = await this.loadIndexDocCount(indexPattern.title); - indexHasSmallDocCount = indexDocCount <= DEFAULT_MAX_RESULT_WINDOW; - } catch (error) { - // retrieving index count is a nice to have and is not essential - // do not interrupt user flow if unable to retrieve count - } - if (!this._isMounted) { return; } @@ -124,43 +110,71 @@ export class CreateSourceEditor extends Component { return; } + const geoFields = getGeoFields(indexPattern.fields); this.setState({ isLoadingIndexPattern: false, indexPattern: indexPattern, - filterByMapBounds: !indexHasSmallDocCount, // Turn off filterByMapBounds when index contains a limited number of documents - showFilterByBoundsSwitch: indexHasSmallDocCount, + geoFields, }); - //make default selection - const geoFields = getGeoFields(indexPattern.fields); - if (geoFields[0]) { - this.onGeoFieldSelect(geoFields[0].name); + if (geoFields.length) { + // make default selection, prefer aggregatable field over the first available + const firstAggregatableGeoField = geoFields.find(geoField => { + return geoField.aggregatable; + }); + const defaultGeoFieldName = firstAggregatableGeoField + ? firstAggregatableGeoField + : geoFields[0]; + this._onGeoFieldSelect(defaultGeoFieldName.name); } }, 300); - onGeoFieldSelect = geoField => { + _onGeoFieldSelect = geoFieldName => { + // Respect previous scaling type selection unless newly selected geo field does not support clustering. + const scalingType = + this.state.scalingType === SCALING_TYPES.CLUSTERS && + !isGeoFieldAggregatable(this.state.indexPattern, geoFieldName) + ? SCALING_TYPES.LIMIT + : this.state.scalingType; this.setState( { - geoField, + geoFieldName, + scalingType, }, - this.previewLayer + this._previewLayer ); }; - onFilterByMapBoundsChange = event => { + _onScalingPropChange = ({ propName, value }) => { this.setState( { - filterByMapBounds: event.target.checked, + [propName]: value, }, - this.previewLayer + this._previewLayer ); }; - previewLayer = () => { - const { indexPatternId, geoField, filterByMapBounds } = this.state; + _previewLayer = () => { + const { + indexPatternId, + geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } = this.state; const sourceConfig = - indexPatternId && geoField ? { indexPatternId, geoField, filterByMapBounds } : null; + indexPatternId && geoFieldName + ? { + indexPatternId, + geoField: geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } + : null; this.props.onSourceConfigChange(sourceConfig); }; @@ -183,56 +197,35 @@ export class CreateSourceEditor extends Component { placeholder={i18n.translate('xpack.maps.source.esSearch.selectLabel', { defaultMessage: 'Select geo field', })} - value={this.state.geoField} - onChange={this.onGeoFieldSelect} - fields={ - this.state.indexPattern ? getGeoFields(this.state.indexPattern.fields) : undefined - } + value={this.state.geoFieldName} + onChange={this._onGeoFieldSelect} + fields={this.state.geoFields} /> ); } - _renderFilterByMapBounds() { - if (!this.state.showFilterByBoundsSwitch) { + _renderScalingPanel() { + if (!this.state.indexPattern || !this.state.geoFieldName) { return null; } return ( - -

- -

-

- -

-
- - - - + +
); } @@ -265,7 +258,7 @@ export class CreateSourceEditor extends Component { ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 440b9aa89a945..cd44ef49623fa 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -149,11 +149,14 @@ export class ESSearchSource extends AbstractESSource { } renderSourceSettingsEditor({ onChange }) { + const getGeoField = () => { + return this._getGeoField(); + }; return ( ({})); + +jest.mock('./load_index_settings', () => ({ + loadIndexSettings: async () => { + return { maxInnerResultWindow: 100, maxResultWindow: 10000 }; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ScalingForm } from './scaling_form'; +import { SCALING_TYPES } from '../../../../common/constants'; + +const defaultProps = { + filterByMapBounds: true, + indexPatternId: 'myIndexPattern', + onChange: () => {}, + scalingType: SCALING_TYPES.LIMIT, + supportsClustering: true, + termFields: [], + topHitsSize: 1, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should not render clusters option when clustering is not supported', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render top hits form when scaling type is TOP_HITS', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx new file mode 100644 index 0000000000000..c5950f1132974 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.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, { Fragment, Component } from 'react'; +import { + EuiFormRow, + EuiSwitch, + EuiSwitchEvent, + EuiTitle, + EuiSpacer, + EuiHorizontalRule, + EuiRadioGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { SingleFieldSelect } from '../../../components/single_field_select'; + +// @ts-ignore +import { indexPatternService } from '../../../kibana_services'; +// @ts-ignore +import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; +// @ts-ignore +import { ValidatedRange } from '../../../components/validated_range'; +import { + DEFAULT_MAX_INNER_RESULT_WINDOW, + DEFAULT_MAX_RESULT_WINDOW, + SCALING_TYPES, + LAYER_TYPE, +} from '../../../../common/constants'; +// @ts-ignore +import { loadIndexSettings } from './load_index_settings'; +import { IFieldType } from '../../../../../../../../src/plugins/data/public'; +import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view'; + +interface Props { + filterByMapBounds: boolean; + indexPatternId: string; + onChange: (args: OnSourceChangeArgs) => void; + scalingType: SCALING_TYPES; + supportsClustering: boolean; + termFields: IFieldType[]; + topHitsSplitField?: string; + topHitsSize: number; +} + +interface State { + maxInnerResultWindow: number; + maxResultWindow: number; +} + +export class ScalingForm extends Component { + state = { + maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, + }; + _isMounted = false; + + componentDidMount() { + this._isMounted = true; + this.loadIndexSettings(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadIndexSettings() { + try { + const indexPattern = await indexPatternService.get(this.props.indexPatternId); + const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); + if (this._isMounted) { + this.setState({ maxInnerResultWindow, maxResultWindow }); + } + } catch (err) { + return; + } + } + + _onScalingTypeChange = (optionId: string): void => { + const layerType = + optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; + this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); + }; + + _onFilterByMapBoundsChange = (event: EuiSwitchEvent) => { + this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); + }; + + _onTopHitsSplitFieldChange = (topHitsSplitField: string) => { + this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); + }; + + _onTopHitsSizeChange = (size: number) => { + this.props.onChange({ propName: 'topHitsSize', value: size }); + }; + + _renderTopHitsForm() { + let sizeSlider; + if (this.props.topHitsSplitField) { + sizeSlider = ( + + + + ); + } + + return ( + + + + + + {sizeSlider} + + ); + } + + render() { + const scalingOptions = [ + { + id: SCALING_TYPES.LIMIT, + label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { + defaultMessage: 'Limit results to {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }, + { + id: SCALING_TYPES.TOP_HITS, + label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { + defaultMessage: 'Show top hits per entity.', + }), + }, + ]; + if (this.props.supportsClustering) { + scalingOptions.push({ + id: SCALING_TYPES.CLUSTERS, + label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { + defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }); + } + + let filterByBoundsSwitch; + if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { + filterByBoundsSwitch = ( + + + + ); + } + + let scalingForm = null; + if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { + scalingForm = ( + + + {this._renderTopHitsForm()} + + ); + } + + return ( + + +
+ +
+
+ + + + + + + + {filterByBoundsSwitch} + + {scalingForm} +
+ ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index 4d1e32087ab8c..9c92ec5801e49 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -6,34 +6,18 @@ import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFormRow, - EuiSwitch, - EuiSelect, - EuiTitle, - EuiPanel, - EuiSpacer, - EuiHorizontalRule, - EuiRadioGroup, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { TooltipSelector } from '../../../components/tooltip_selector'; import { getIndexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; -import { ValidatedRange } from '../../../components/validated_range'; -import { - DEFAULT_MAX_INNER_RESULT_WINDOW, - DEFAULT_MAX_RESULT_WINDOW, - SORT_ORDER, - SCALING_TYPES, - LAYER_TYPE, -} from '../../../../common/constants'; +import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; -import { loadIndexSettings } from './load_index_settings'; import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; +import { ScalingForm } from './scaling_form'; export class UpdateSourceEditor extends Component { static propTypes = { @@ -52,33 +36,18 @@ export class UpdateSourceEditor extends Component { sourceFields: null, termFields: null, sortFields: null, - maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, - maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, supportsClustering: false, }; componentDidMount() { this._isMounted = true; this.loadFields(); - this.loadIndexSettings(); } componentWillUnmount() { this._isMounted = false; } - async loadIndexSettings() { - try { - const indexPattern = await getIndexPatternService().get(this.props.indexPatternId); - const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); - if (this._isMounted) { - this.setState({ maxInnerResultWindow, maxResultWindow }); - } - } catch (err) { - return; - } - } - async loadFields() { let indexPattern; try { @@ -133,85 +102,14 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); }; - _onScalingTypeChange = optionId => { - const layerType = - optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; - this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); - }; - - _onFilterByMapBoundsChange = event => { - this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); - }; - - onTopHitsSplitFieldChange = topHitsSplitField => { - this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); - }; - - onSortFieldChange = sortField => { + _onSortFieldChange = sortField => { this.props.onChange({ propName: 'sortField', value: sortField }); }; - onSortOrderChange = e => { + _onSortOrderChange = e => { this.props.onChange({ propName: 'sortOrder', value: e.target.value }); }; - onTopHitsSizeChange = size => { - this.props.onChange({ propName: 'topHitsSize', value: size }); - }; - - _renderTopHitsForm() { - let sizeSlider; - if (this.props.topHitsSplitField) { - sizeSlider = ( - - - - ); - } - - return ( - - - - - - {sizeSlider} - - ); - } - _renderTooltipsPanel() { return ( @@ -257,7 +155,7 @@ export class UpdateSourceEditor extends Component { defaultMessage: 'Select sort field', })} value={this.props.sortField} - onChange={this.onSortFieldChange} + onChange={this._onSortFieldChange} fields={this.state.sortFields} compressed /> @@ -286,7 +184,7 @@ export class UpdateSourceEditor extends Component { }, ]} value={this.props.sortOrder} - onChange={this.onSortOrderChange} + onChange={this._onSortOrderChange} compressed /> @@ -295,78 +193,18 @@ export class UpdateSourceEditor extends Component { } _renderScalingPanel() { - const scalingOptions = [ - { - id: SCALING_TYPES.LIMIT, - label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { - defaultMessage: 'Limit results to {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }, - { - id: SCALING_TYPES.TOP_HITS, - label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { - defaultMessage: 'Show top hits per entity.', - }), - }, - ]; - if (this.state.supportsClustering) { - scalingOptions.push({ - id: SCALING_TYPES.CLUSTERS, - label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { - defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }); - } - - let filterByBoundsSwitch; - if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { - filterByBoundsSwitch = ( - - - - ); - } - - let scalingForm = null; - if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { - scalingForm = ( - - - {this._renderTopHitsForm()} - - ); - } - return ( - -
- -
-
- - - - - - - - {filterByBoundsSwitch} - - {scalingForm} +
); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js index e8a845c4b1669..65a91ce03994a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -40,11 +40,3 @@ test('should enable sort order select when sort field provided', async () => { expect(component).toMatchSnapshot(); }); - -test('should render top hits form when scaling type is TOP_HITS', async () => { - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts index 963a30c7413e8..b565cb9108aea 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts @@ -7,7 +7,7 @@ import { AbstractVectorSource } from './vector_source'; import { IVectorSource } from './vector_source'; import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/public'; -import { VectorLayerRequestMeta } from '../../../common/data_request_descriptor_types'; +import { VectorSourceRequestMeta } from '../../../common/data_request_descriptor_types'; export interface IESSource extends IVectorSource { getId(): string; @@ -16,7 +16,7 @@ export interface IESSource extends IVectorSource { getGeoFieldName(): string; getMaxResultWindow(): Promise; makeSearchSource( - searchFilters: VectorLayerRequestMeta, + searchFilters: VectorSourceRequestMeta, limit: number, initialSearchContext?: object ): Promise; @@ -29,7 +29,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource getGeoFieldName(): string; getMaxResultWindow(): Promise; makeSearchSource( - searchFilters: VectorLayerRequestMeta, + searchFilters: VectorSourceRequestMeta, limit: number, initialSearchContext?: object ): Promise; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js index 8b079b5202f7f..9dc3067a70436 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.js @@ -231,7 +231,7 @@ export class AbstractESSource extends AbstractVectorSource { } } - _getGeoField = async () => { + async _getGeoField() { const indexPattern = await this.getIndexPattern(); const geoField = indexPattern.fields.getByName(this._descriptor.geoField); if (!geoField) { @@ -243,7 +243,7 @@ export class AbstractESSource extends AbstractVectorSource { ); } return geoField; - }; + } async getDisplayName() { try { diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts index 2ca18e47a4bf9..e1706ad7b7d77 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts @@ -9,15 +9,28 @@ import { ILayer } from '../layer'; export interface ISource { createDefaultLayer(): ILayer; - getDisplayName(): Promise; destroy(): void; + getDisplayName(): Promise; getInspectorAdapters(): object; + isFieldAware(): boolean; + isFilterByMapBounds(): boolean; + isGeoGridPrecisionAware(): boolean; + isQueryAware(): boolean; + isRefreshTimerAware(): Promise; + isTimeAware(): Promise; } export class AbstractSource implements ISource { constructor(sourceDescriptor: AbstractSourceDescriptor, inspectorAdapters: object); + + destroy(): void; createDefaultLayer(): ILayer; getDisplayName(): Promise; - destroy(): void; getInspectorAdapters(): object; + isFieldAware(): boolean; + isFilterByMapBounds(): boolean; + isGeoGridPrecisionAware(): boolean; + isQueryAware(): boolean; + isRefreshTimerAware(): Promise; + isTimeAware(): Promise; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts index 14fc23751ac1a..fd585e100924e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { FeatureCollection } from 'geojson'; import { AbstractSource, ISource } from './source'; import { IField } from '../fields/field'; import { ESSearchSourceResponseMeta } from '../../../common/data_request_descriptor_types'; @@ -12,7 +13,7 @@ import { ESSearchSourceResponseMeta } from '../../../common/data_request_descrip export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; export type GeoJsonWithMeta = { - data: unknown; // geojson feature collection + data: FeatureCollection; meta?: GeoJsonFetchMeta; }; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js index a619eaba21aef..09c7d76db1691 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js @@ -78,17 +78,26 @@ export function getColorRampCenterColor(colorRampName) { // Returns an array of color stops // [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getOrdinalColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) { +export function getOrdinalColorRampStops(colorRampName, min, max) { if (!colorRampName) { return null; } - return getHexColorRangeStrings(colorRampName, numberColors).reduce( - (accu, stopColor, idx, srcArr) => { - const stopNumber = idx / srcArr.length; // number between 0 and 1, increasing as index increases - return [...accu, stopNumber, stopColor]; - }, - [] - ); + + if (min > max) { + return null; + } + + const hexColors = getHexColorRangeStrings(colorRampName, GRADIENT_INTERVALS); + if (max === min) { + //just return single stop value + return [max, hexColors[hexColors.length - 1]]; + } + + const delta = max - min; + return hexColors.reduce((accu, stopColor, idx, srcArr) => { + const stopNumber = min + (delta * idx) / srcArr.length; + return [...accu, stopNumber, stopColor]; + }, []); } export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map(colorRampName => ({ diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js index 5a8289ba903f3..9a5ece01d5206 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js @@ -60,26 +60,30 @@ describe('getColorRampCenterColor', () => { }); describe('getColorRampStops', () => { - it('Should create color stops for color ramp', () => { - expect(getOrdinalColorRampStops('Blues')).toEqual([ + it('Should create color stops for custom range', () => { + expect(getOrdinalColorRampStops('Blues', 0, 1000)).toEqual([ 0, '#f7faff', - 0.125, + 125, '#ddeaf7', - 0.25, + 250, '#c5daee', - 0.375, + 375, '#9dc9e0', - 0.5, + 500, '#6aadd5', - 0.625, + 625, '#4191c5', - 0.75, + 750, '#2070b4', - 0.875, + 875, '#072f6b', ]); }); + + it('Should snap to end of color stops for identical range', () => { + expect(getOrdinalColorRampStops('Blues', 23, 23)).toEqual([23, '#072f6b']); + }); }); describe('getLinearGradient', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js index dc3cfc3ffbdb8..d769fe0da9ec2 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js @@ -14,6 +14,11 @@ import { getOrdinalColorRampStops } from '../color_utils'; import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; +//The heatmap range chosen hear runs from 0 to 1. It is arbitrary. +//Weighting is on the raw count/sum values. +const MIN_RANGE = 0; +const MAX_RANGE = 1; + export class HeatmapStyle extends AbstractStyle { static type = LAYER_STYLE_TYPE.HEATMAP; @@ -80,7 +85,7 @@ export class HeatmapStyle extends AbstractStyle { const { colorRampName } = this._descriptor; if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getOrdinalColorRampStops(colorRampName); + const colorStops = getOrdinalColorRampStops(colorRampName, MIN_RANGE, MAX_RANGE); mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 417426f12fc98..146bc40aa8531 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -5,8 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; -import _ from 'lodash'; -import { getComputedFieldName, getOtherCategoryLabel } from '../style_util'; +import { getOtherCategoryLabel, makeMbClampedNumberExpression } from '../style_util'; import { getOrdinalColorRampStops, getColorPalette } from '../../color_utils'; import { ColorGradient } from '../../components/color_gradient'; import React from 'react'; @@ -23,6 +22,7 @@ import { COLOR_MAP_TYPE } from '../../../../../common/constants'; import { isCategoricalStopsInvalid } from '../components/color/color_stops_utils'; const EMPTY_STOPS = { stops: [], defaultColor: null }; +const RGBA_0000 = 'rgba(0,0,0,0)'; export class DynamicColorProperty extends DynamicStyleProperty { syncCircleColorWithMb(mbLayerId, mbMap, alpha) { @@ -70,6 +70,17 @@ export class DynamicColorProperty extends DynamicStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); } + supportsFieldMeta() { + if (!this.isComplete() || !this._field.supportsFieldMeta()) { + return false; + } + + return ( + (this.isCategorical() && !this._options.useCustomColorPalette) || + (this.isOrdinal() && !this._options.useCustomColorRamp) + ); + } + isOrdinal() { return ( typeof this._options.type === 'undefined' || this._options.type === COLOR_MAP_TYPE.ORDINAL @@ -80,28 +91,20 @@ export class DynamicColorProperty extends DynamicStyleProperty { return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; } - isCustomOrdinalColorRamp() { - return this._options.useCustomColorRamp; - } - supportsMbFeatureState() { return true; } - isOrdinalScaled() { - return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); - } - isOrdinalRanged() { - return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); + return this.isOrdinal() && !this._options.useCustomColorRamp; } hasOrdinalBreaks() { - return (this.isOrdinal() && this.isCustomOrdinalColorRamp()) || this.isCategorical(); + return (this.isOrdinal() && this._options.useCustomColorRamp) || this.isCategorical(); } _getMbColor() { - if (!_.get(this._options, 'field.name')) { + if (!this._field || !this._field.getName()) { return null; } @@ -111,7 +114,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { } _getOrdinalColorMbExpression() { - const targetName = getComputedFieldName(this._styleName, this._options.field.name); + const targetName = this._field.getName(); if (this._options.useCustomColorRamp) { if (!this._options.customColorRamp || !this._options.customColorRamp.length) { // custom color ramp config is not complete @@ -122,27 +125,44 @@ export class DynamicColorProperty extends DynamicStyleProperty { return [...accumulatedStops, nextStop.stop, nextStop.color]; }, []); const firstStopValue = colorStops[0]; - const lessThenFirstStopValue = firstStopValue - 1; + const lessThanFirstStopValue = firstStopValue - 1; return [ 'step', - ['coalesce', ['feature-state', targetName], lessThenFirstStopValue], - 'rgba(0,0,0,0)', // MB will assign the base value to any features that is below the first stop value + ['coalesce', ['feature-state', targetName], lessThanFirstStopValue], + RGBA_0000, // MB will assign the base value to any features that is below the first stop value ...colorStops, ]; - } + } else { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!rangeFieldMeta) { + return null; + } - const colorStops = getOrdinalColorRampStops(this._options.color); - if (!colorStops) { - return null; + const colorStops = getOrdinalColorRampStops( + this._options.color, + rangeFieldMeta.min, + rangeFieldMeta.max + ); + if (!colorStops) { + return null; + } + + const lessThanFirstStopValue = rangeFieldMeta.min - 1; + return [ + 'interpolate', + ['linear'], + makeMbClampedNumberExpression({ + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + lookupFunction: 'feature-state', + fallback: lessThanFirstStopValue, + fieldName: targetName, + }), + lessThanFirstStopValue, + RGBA_0000, + ...colorStops, + ]; } - return [ - 'interpolate', - ['linear'], - ['coalesce', ['feature-state', targetName], -1], - -1, - 'rgba(0,0,0,0)', - ...colorStops, - ]; } _getColorPaletteStops() { @@ -220,7 +240,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { } mbStops.push(defaultColor); //last color is default color - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } renderRangeLegendHeader() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index 755fc72d52798..b19c25b369848 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -75,16 +75,10 @@ class MockLayer { } } -const makeProperty = options => { - return new DynamicColorProperty( - options, - VECTOR_STYLES.LINE_COLOR, - mockField, - new MockLayer(), - () => { - return x => x + '_format'; - } - ); +const makeProperty = (options, field = mockField) => { + return new DynamicColorProperty(options, VECTOR_STYLES.LINE_COLOR, field, new MockLayer(), () => { + return x => x + '_format'; + }); }; const defaultLegendParams = { @@ -236,7 +230,72 @@ test('Should pluck the categorical style-meta from fieldmeta', async () => { }); }); -describe('get mapbox color expression', () => { +describe('supportsFieldMeta', () => { + test('should support it when field does for ordinals', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(true); + }); + + test('should support it when field does for categories', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.CATEGORICAL, + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(true); + }); + + test('should not support it when field does not', () => { + const field = Object.create(mockField); + field.supportsFieldMeta = function() { + return false; + }; + + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions, field); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when field config not complete', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions, null); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when using custom ramp for ordinals', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + useCustomColorRamp: true, + customColorRamp: [], + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when using custom palette for categories', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: true, + customColorPalette: [], + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); +}); + +describe('get mapbox color expression (via internal _getMbColor)', () => { describe('ordinal color ramp', () => { test('should return null when field is not provided', async () => { const dynamicStyleOptions = { @@ -259,44 +318,46 @@ describe('get mapbox color expression', () => { test('should return null when color ramp is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toBeNull(); }); - test('should return mapbox expression for color ramp', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, color: 'Blues', }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'interpolate', ['linear'], - ['coalesce', ['feature-state', '__kbn__dynamic__myField__lineColor'], -1], + [ + 'coalesce', + [ + 'case', + ['==', ['feature-state', 'foobar'], null], + -1, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0], + ], + -1, + ], -1, 'rgba(0,0,0,0)', 0, '#f7faff', - 0.125, + 12.5, '#ddeaf7', - 0.25, + 25, '#c5daee', - 0.375, + 37.5, '#9dc9e0', - 0.5, + 50, '#6aadd5', - 0.625, + 62.5, '#4191c5', - 0.75, + 75, '#2070b4', - 0.875, + 87.5, '#072f6b', ]); }); @@ -306,9 +367,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorRamp is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, }; const colorProperty = makeProperty(dynamicStyleOptions); @@ -318,9 +376,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorRamp is empty', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, customColorRamp: [], }; @@ -331,9 +386,6 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for custom color ramp', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, customColorRamp: [ { stop: 10, color: '#f7faff' }, @@ -343,7 +395,7 @@ describe('get mapbox color expression', () => { const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'step', - ['coalesce', ['feature-state', '__kbn__dynamic__myField__lineColor'], 9], + ['coalesce', ['feature-state', 'foobar'], 9], 'rgba(0,0,0,0)', 10, '#f7faff', @@ -376,9 +428,6 @@ describe('get mapbox color expression', () => { test('should return null when color palette is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toBeNull(); @@ -387,15 +436,12 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for color palette', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, colorCategory: 'palette_0', }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'match', - ['to-string', ['get', 'myField']], + ['to-string', ['get', 'foobar']], 'US', '#54B399', 'CN', @@ -409,9 +455,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorPalette is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, }; const colorProperty = makeProperty(dynamicStyleOptions); @@ -421,9 +464,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorPalette is empty', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, customColorPalette: [], }; @@ -434,9 +474,6 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for custom color palette', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, customColorPalette: [ { stop: null, color: '#f7faff' }, @@ -446,7 +483,7 @@ describe('get mapbox color expression', () => { const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'match', - ['to-string', ['get', 'myField']], + ['to-string', ['get', 'foobar']], 'MX', '#072f6b', '#f7faff', diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js index c492efbdf4ba3..05e2ad06842ce 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js @@ -81,7 +81,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(getMakiIconId(style, iconPixelSize)); }); mbStops.push(getMakiIconId(fallback, iconPixelSize)); //last item is fallback style for anything that does not match provided stops - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } _getMbIconAnchorExpression() { @@ -98,7 +98,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(getMakiSymbolAnchor(style)); }); mbStops.push(getMakiSymbolAnchor(fallback)); //last item is fallback style for anything that does not match provided stops - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } _isIconDynamicConfigComplete() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js index 96408b3d2229e..ae4d935e2457b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js @@ -25,8 +25,4 @@ export class DynamicOrientationProperty extends DynamicStyleProperty { supportsMbFeatureState() { return false; } - - isOrdinalScaled() { - return false; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index 7626f8c9b4158..8b3f670bfa528 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -5,7 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; - +import { makeMbClampedNumberExpression } from '../style_util'; import { HALF_LARGE_MAKI_ICON_SIZE, LARGE_MAKI_ICON_SIZE, @@ -74,17 +74,24 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncIconSizeWithMb(symbolLayerId, mbMap) { - if (this._isSizeDynamicConfigComplete(this._options)) { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (this._isSizeDynamicConfigComplete(this._options) && rangeFieldMeta) { const halfIconPixels = this.getIconPixelSize() / 2; - const targetName = this.getComputedFieldName(); + const targetName = this.getFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ 'interpolate', ['linear'], - ['coalesce', ['get', targetName], 0], - 0, + makeMbClampedNumberExpression({ + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + fallback: 0, + lookupFunction: 'get', + fieldName: targetName, + }), + rangeFieldMeta.min, this._options.minSize / halfIconPixels, - 1, + rangeFieldMeta.max, this._options.maxSize / halfIconPixels, ]); } else { @@ -113,25 +120,35 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } getMbSizeExpression() { - if (this._isSizeDynamicConfigComplete(this._options)) { - return this._getMbDataDrivenSize({ - targetName: this.getComputedFieldName(), - minSize: this._options.minSize, - maxSize: this._options.maxSize, - }); + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!this._isSizeDynamicConfigComplete(this._options) || !rangeFieldMeta) { + return null; } - return null; + + return this._getMbDataDrivenSize({ + targetName: this.getFieldName(), + minSize: this._options.minSize, + maxSize: this._options.maxSize, + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + }); } - _getMbDataDrivenSize({ targetName, minSize, maxSize }) { + _getMbDataDrivenSize({ targetName, minSize, maxSize, minValue, maxValue }) { const lookup = this.supportsMbFeatureState() ? 'feature-state' : 'get'; return [ 'interpolate', ['linear'], - ['coalesce', [lookup, targetName], 0], - 0, + makeMbClampedNumberExpression({ + lookupFunction: lookup, + maxValue, + minValue, + fieldName: targetName, + fallback: 0, + }), + minValue, minSize, - 1, + maxValue, maxSize, ]; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts index 25063944b8891..a83dd55c0c175 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts @@ -12,8 +12,6 @@ import { DynamicStylePropertyOptions, } from '../../../../../common/style_property_descriptor_types'; import { IField } from '../../../fields/field'; -import { IVectorLayer } from '../../../vector_layer'; -import { IVectorSource } from '../../../sources/vector_source'; import { CategoryFieldMeta, RangeFieldMeta } from '../../../../../common/descriptor_types'; export interface IDynamicStyleProperty extends IStyleProperty { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 68e06bacfa7b7..ea521f8749d80 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -13,7 +13,6 @@ import { SOURCE_META_ID_ORIGIN, FIELD_ORIGIN, } from '../../../../../common/constants'; -import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -109,13 +108,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return this._field ? this._field.getName() : ''; } - getComputedFieldName() { - if (!this.isComplete()) { - return null; - } - return getComputedFieldName(this._styleName, this.getField().getName()); - } - isDynamic() { return true; } @@ -150,13 +142,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } supportsFieldMeta() { - if (this.isOrdinal()) { - return this.isComplete() && this.isOrdinalScaled() && this._field.supportsFieldMeta(); - } else if (this.isCategorical()) { - return this.isComplete() && this._field.supportsFieldMeta(); - } else { - return false; - } + return this.isComplete() && this._field.supportsFieldMeta(); } async getFieldMetaRequest() { @@ -173,10 +159,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return true; } - isOrdinalScaled() { - return true; - } - getFieldMetaOptions() { return _.get(this.getOptions(), 'fieldMetaOptions', {}); } @@ -296,19 +278,9 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } } - getMbValue(value) { - if (!this.isOrdinal()) { - return this.formatField(value); - } - + getNumericalMbFeatureStateValue(value) { const valueAsFloat = parseFloat(value); - if (this.isOrdinalScaled()) { - return scaleValue(valueAsFloat, this.getRangeFieldMeta()); - } - if (isNaN(valueAsFloat)) { - return 0; - } - return valueAsFloat; + return isNaN(valueAsFloat) ? null : valueAsFloat; } renderBreakedLegend() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js index c561ec128dec5..de868f3f92650 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js @@ -28,8 +28,4 @@ export class DynamicTextProperty extends DynamicStyleProperty { supportsMbFeatureState() { return false; } - - isOrdinalScaled() { - return false; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js index 7119b659c1232..3016b15d0a05c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -31,21 +31,27 @@ export class LabelBorderSizeProperty extends AbstractStyleProperty { } syncLabelBorderSizeWithMb(mbLayerId, mbMap) { - const widthRatio = getWidthRatio(this.getOptions().size); - if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); - } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + return; + } + + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ - 'max', - ['*', labelSizeExpression, widthRatio], - 1, - ]); - } else { - const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); - const labelBorderSize = Math.max(labelSize * widthRatio, 1); - mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + if (labelSizeExpression) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + return; + } } + + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index 2859b8c0e5a56..0820568468439 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js @@ -34,22 +34,6 @@ export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatu }, true); } -export function scaleValue(value, range) { - if (isNaN(value) || !range) { - return -1; //Nothing to scale, put outside scaled range - } - - if (range.delta === 0 || value >= range.max) { - return 1; //snap to end of scaled range - } - - if (value <= range.min) { - return 0; //snap to beginning of scaled range - } - - return (value - range.min) / range.delta; -} - export function assignCategoriesToPalette({ categories, paletteValues }) { const stops = []; let fallback = null; @@ -70,3 +54,23 @@ export function assignCategoriesToPalette({ categories, paletteValues }) { fallback, }; } + +export function makeMbClampedNumberExpression({ + lookupFunction, + fieldName, + minValue, + maxValue, + fallback, +}) { + const clamp = ['max', ['min', ['to-number', [lookupFunction, fieldName]], maxValue], minValue]; + return [ + 'coalesce', + [ + 'case', + ['==', [lookupFunction, fieldName], null], + minValue - 1, //== does a JS-y like check where returns true for null and undefined + clamp, + ], + fallback, + ]; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js index 2be31c0107193..76bbfc84e3892 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isOnlySingleFeatureType, scaleValue, assignCategoriesToPalette } from './style_util'; +import { isOnlySingleFeatureType, assignCategoriesToPalette } from './style_util'; import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; describe('isOnlySingleFeatureType', () => { @@ -62,32 +62,6 @@ describe('isOnlySingleFeatureType', () => { }); }); -describe('scaleValue', () => { - test('Should scale value between 0 and 1', () => { - expect(scaleValue(5, { min: 0, max: 10, delta: 10 })).toBe(0.5); - }); - - test('Should snap value less then range min to 0', () => { - expect(scaleValue(-1, { min: 0, max: 10, delta: 10 })).toBe(0); - }); - - test('Should snap value greater then range max to 1', () => { - expect(scaleValue(11, { min: 0, max: 10, delta: 10 })).toBe(1); - }); - - test('Should snap value to 1 when tere is not range delta', () => { - expect(scaleValue(10, { min: 10, max: 10, delta: 0 })).toBe(1); - }); - - test('Should put value as -1 when value is not provided', () => { - expect(scaleValue(undefined, { min: 0, max: 10, delta: 10 })).toBe(-1); - }); - - test('Should put value as -1 when range is not provided', () => { - expect(scaleValue(5, undefined)).toBe(-1); - }); -}); - describe('assignCategoriesToPalette', () => { test('Categories and palette values have same length', () => { const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index b5a0b51727936..ae5d148e43cfd 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -503,11 +503,19 @@ export class VectorStyle extends AbstractStyle { const dynamicStyleProp = dynamicStyleProps[j]; const name = dynamicStyleProp.getField().getName(); const computedName = getComputedFieldName(dynamicStyleProp.getStyleName(), name); - const styleValue = dynamicStyleProp.getMbValue(feature.properties[name]); + const rawValue = feature.properties[name]; if (dynamicStyleProp.supportsMbFeatureState()) { - tmpFeatureState[computedName] = styleValue; + tmpFeatureState[name] = dynamicStyleProp.getNumericalMbFeatureStateValue(rawValue); //the same value will be potentially overridden multiple times, if the name remains identical } else { - feature.properties[computedName] = styleValue; + //in practice, a new system property will only be created for: + // - label text: this requires the value to be formatted first. + // - icon orientation: this is a lay-out property which do not support feature-state (but we're still coercing to a number) + + const formattedValue = dynamicStyleProp.isOrdinal() + ? dynamicStyleProp.getNumericalMbFeatureStateValue(rawValue) + : dynamicStyleProp.formatField(rawValue); + + feature.properties[computedName] = formattedValue; } } tmpFeatureIdentifier.source = mbSourceId; diff --git a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts index 8ce38a128ebc4..1cb99dcbc1a70 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts @@ -7,7 +7,7 @@ import { TileLayer } from './tile_layer'; import { EMS_XYZ } from '../../common/constants'; import { XYZTMSSourceDescriptor } from '../../common/descriptor_types'; -import { ITMSSource } from './sources/tms_source'; +import { ITMSSource, AbstractTMSSource } from './sources/tms_source'; import { ILayer } from './layer'; const sourceDescriptor: XYZTMSSourceDescriptor = { @@ -16,9 +16,10 @@ const sourceDescriptor: XYZTMSSourceDescriptor = { id: 'foobar', }; -class MockTileSource implements ITMSSource { +class MockTileSource extends AbstractTMSSource implements ITMSSource { private readonly _descriptor: XYZTMSSourceDescriptor; constructor(descriptor: XYZTMSSourceDescriptor) { + super(descriptor, {}); this._descriptor = descriptor; } createDefaultLayer(): ILayer { @@ -32,14 +33,6 @@ class MockTileSource implements ITMSSource { async getUrlTemplate(): Promise { return 'template/{x}/{y}/{z}.png'; } - - destroy(): void { - // no-op - } - - getInspectorAdapters(): object { - return {}; - } } describe('TileLayer', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts similarity index 74% rename from x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js rename to x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts index cc8dff14ec4f0..fb07a523e1e07 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts @@ -6,19 +6,25 @@ import { assignFeatureIds } from './assign_feature_ids'; import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; +import { FeatureCollection, Feature, Point } from 'geojson'; const featureId = 'myFeature1'; +const geometry: Point = { + type: 'Point', + coordinates: [0, 0], +}; + +const defaultFeature: Feature = { + type: 'Feature', + geometry, + properties: {}, +}; + test('should provide unique id when feature.id is not provided', () => { - const featureCollection = { - features: [ - { - properties: {}, - }, - { - properties: {}, - }, - ], + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', + features: [{ ...defaultFeature }, { ...defaultFeature }], }; const updatedFeatureCollection = assignFeatureIds(featureCollection); @@ -26,16 +32,18 @@ test('should provide unique id when feature.id is not provided', () => { const feature2 = updatedFeatureCollection.features[1]; expect(typeof feature1.id).toBe('number'); expect(typeof feature2.id).toBe('number'); + // @ts-ignore expect(feature1.id).toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); expect(feature1.id).not.toBe(feature2.id); }); test('should preserve feature id when provided', () => { - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: featureId, - properties: {}, }, ], }; @@ -43,16 +51,19 @@ test('should preserve feature id when provided', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; expect(typeof feature1.id).toBe('number'); + // @ts-ignore expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); }); test('should preserve feature id for falsy value', () => { - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: 0, - properties: {}, }, ], }; @@ -60,15 +71,19 @@ test('should preserve feature id for falsy value', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; expect(typeof feature1.id).toBe('number'); + // @ts-ignore expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(0); }); test('should not modify original feature properties', () => { const featureProperties = {}; - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: featureId, properties: featureProperties, }, @@ -77,6 +92,7 @@ test('should not modify original feature properties', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); expect(featureProperties).not.toHaveProperty(FEATURE_ID_PROPERTY_NAME); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts similarity index 90% rename from x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js rename to x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts index a943b0b22a189..e5c170a803174 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts @@ -5,17 +5,18 @@ */ import _ from 'lodash'; +import { FeatureCollection, Feature } from 'geojson'; import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; let idCounter = 0; -function generateNumericalId() { +function generateNumericalId(): number { const newId = idCounter < Number.MAX_SAFE_INTEGER ? idCounter : 0; idCounter = newId + 1; return newId; } -export function assignFeatureIds(featureCollection) { +export function assignFeatureIds(featureCollection: FeatureCollection): FeatureCollection { // wrt https://github.com/elastic/kibana/issues/39317 // In constrained resource environments, mapbox-gl may throw a stackoverflow error due to hitting the browser's recursion limit. This crashes Kibana. // This error is thrown in mapbox-gl's quicksort implementation, when it is sorting all the features by id. @@ -32,7 +33,7 @@ export function assignFeatureIds(featureCollection) { } const randomizedIds = _.shuffle(ids); - const features = []; + const features: Feature[] = []; for (let i = 0; i < featureCollection.features.length; i++) { const numericId = randomizedIds[i]; const feature = featureCollection.features[i]; diff --git a/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js b/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts similarity index 84% rename from x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js rename to x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts index 7abfee1b184f0..7b75bb0f21b79 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts @@ -4,14 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ import _ from 'lodash'; +// @ts-ignore import turf from 'turf'; import turfBooleanContains from '@turf/boolean-contains'; import { isRefreshOnlyQuery } from './is_refresh_only_query'; +import { ISource } from '../sources/source'; +import { DataMeta } from '../../../common/data_request_descriptor_types'; +import { DataRequest } from './data_request'; const SOURCE_UPDATE_REQUIRED = true; const NO_SOURCE_UPDATE_REQUIRED = false; -export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { +export function updateDueToExtent( + source: ISource, + prevMeta: DataMeta = {}, + nextMeta: DataMeta = {} +) { const extentAware = source.isFilterByMapBounds(); if (!extentAware) { return NO_SOURCE_UPDATE_REQUIRED; @@ -20,7 +28,7 @@ export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { const { buffer: previousBuffer } = prevMeta; const { buffer: newBuffer } = nextMeta; - if (!previousBuffer) { + if (!previousBuffer || !previousBuffer || !newBuffer) { return SOURCE_UPDATE_REQUIRED; } @@ -51,7 +59,15 @@ export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { : SOURCE_UPDATE_REQUIRED; } -export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) { +export async function canSkipSourceUpdate({ + source, + prevDataRequest, + nextMeta, +}: { + source: ISource; + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): Promise { const timeAware = await source.isTimeAware(); const refreshTimerAware = await source.isRefreshTimerAware(); const extentAware = source.isFilterByMapBounds(); @@ -67,7 +83,7 @@ export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) !isQueryAware && !isGeoGridPrecisionAware ) { - return prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); + return !!prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); } if (!prevDataRequest) { @@ -136,7 +152,13 @@ export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) ); } -export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { +export function canSkipStyleMetaUpdate({ + prevDataRequest, + nextMeta, +}: { + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): boolean { if (!prevDataRequest) { return false; } @@ -159,7 +181,13 @@ export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { ); } -export function canSkipFormattersUpdate({ prevDataRequest, nextMeta }) { +export function canSkipFormattersUpdate({ + prevDataRequest, + nextMeta, +}: { + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): boolean { if (!prevDataRequest) { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js b/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts similarity index 78% rename from x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js rename to x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts index f3dc08a7a7a58..48b1340207fd4 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts @@ -4,9 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Query } from '../../../common/map_descriptor'; + // Refresh only query is query where timestamps are different but query is the same. // Triggered by clicking "Refresh" button in QueryBar -export function isRefreshOnlyQuery(prevQuery, newQuery) { +export function isRefreshOnlyQuery( + prevQuery: Query | undefined, + newQuery: Query | undefined +): boolean { if (!prevQuery || !newQuery) { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js b/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts similarity index 87% rename from x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js rename to x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts index 36841dc727dd3..8da6fa2318de9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts @@ -34,14 +34,14 @@ const POINT_MB_FILTER = [ const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER]; -export function getFillFilterExpression(hasJoins) { +export function getFillFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_CLOSED_SHAPE_MB_FILTER : CLOSED_SHAPE_MB_FILTER; } -export function getLineFilterExpression(hasJoins) { +export function getLineFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_ALL_SHAPE_MB_FILTER : ALL_SHAPE_MB_FILTER; } -export function getPointFilterExpression(hasJoins) { +export function getPointFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_POINT_MB_FILTER : POINT_MB_FILTER; } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts index 77e8ab768cd00..390374f761fc7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts @@ -8,7 +8,7 @@ import { AbstractLayer } from './layer'; import { IVectorSource } from './sources/vector_source'; import { VectorLayerDescriptor } from '../../common/descriptor_types'; -import { MapFilters, VectorLayerRequestMeta } from '../../common/data_request_descriptor_types'; +import { MapFilters, VectorSourceRequestMeta } from '../../common/data_request_descriptor_types'; import { ILayer } from './layer'; import { IJoin } from './joins/join'; import { IVectorStyle } from './styles/vector/vector_style'; @@ -45,6 +45,6 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { dataFilters: MapFilters, source: IVectorSource, style: IVectorStyle - ): VectorLayerRequestMeta; + ): VectorSourceRequestMeta; _syncData(syncContext: SyncContext, source: IVectorSource, style: IVectorStyle): Promise; } diff --git a/x-pack/legacy/plugins/maps/server/plugin.js b/x-pack/legacy/plugins/maps/server/plugin.js index 02e38ff54b300..5b52a3eba2f23 100644 --- a/x-pack/legacy/plugins/maps/server/plugin.js +++ b/x-pack/legacy/plugins/maps/server/plugin.js @@ -23,12 +23,15 @@ export class MapPlugin { name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', }), + order: 600, icon: APP_ICON, navLinkId: APP_ID, app: [APP_ID, 'kibana'], catalogue: [APP_ID], privileges: { all: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [MAP_SAVED_OBJECT_TYPE, 'query'], read: ['index-pattern'], @@ -36,6 +39,8 @@ export class MapPlugin { ui: ['save', 'show', 'saveQuery'], }, read: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [], read: [MAP_SAVED_OBJECT_TYPE, 'index-pattern', 'query'], diff --git a/x-pack/legacy/plugins/maps/server/routes.js b/x-pack/legacy/plugins/maps/server/routes.js index 7ca659148449f..6aacfdc41aeea 100644 --- a/x-pack/legacy/plugins/maps/server/routes.js +++ b/x-pack/legacy/plugins/maps/server/routes.js @@ -409,26 +409,6 @@ export function initRoutes(server, licenseUid) { }, }); - server.route({ - method: 'GET', - path: `${ROOT}/indexCount`, - handler: async (request, h) => { - const { server, query } = request; - - if (!query.index) { - return h.response().code(400); - } - - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - try { - const { count } = await callWithRequest(request, 'count', { index: query.index }); - return { count }; - } catch (error) { - return h.response().code(400); - } - }, - }); - server.route({ method: 'GET', path: `/${INDEX_SETTINGS_API_PATH}`, diff --git a/x-pack/legacy/plugins/monitoring/index.js b/x-pack/legacy/plugins/monitoring/index.js index fcf704b5f65d3..ccb45dc1f446f 100644 --- a/x-pack/legacy/plugins/monitoring/index.js +++ b/x-pack/legacy/plugins/monitoring/index.js @@ -9,7 +9,6 @@ import { resolve } from 'path'; import { config } from './config'; import { getUiExports } from './ui_exports'; import { KIBANA_ALERTING_ENABLED } from './common/constants'; -import { telemetryCollectionManager } from '../../../../src/legacy/core_plugins/telemetry/server'; /** * Invokes plugin modules to instantiate the Monitoring plugin for Kibana @@ -32,7 +31,6 @@ export const monitoring = kibana => { if (npMonitoring) { const kbnServerStatus = this.kbnServer.status; npMonitoring.registerLegacyAPI({ - telemetryCollectionManager, getServerStatus: () => { const status = kbnServerStatus.toJSON(); return get(status, 'overall.state'); diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts index deebbccf5aa49..5b2218af1fd52 100644 --- a/x-pack/legacy/plugins/security/index.ts +++ b/x-pack/legacy/plugins/security/index.ts @@ -51,10 +51,7 @@ export const security = (kibana: Record) => uiExports: { hacks: ['plugins/security/hacks/legacy'], injectDefaultVars: (server: Server) => { - return { - secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies, - enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), - }; + return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') }; }, }, diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index c3fc4aea77863..662fb8fb8ef68 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -52,13 +52,21 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[ */ export const SIGNALS_ID = `${APP_ID}.signals`; +/** + * Id for the notifications alerting type + */ +export const NOTIFICATIONS_ID = `${APP_ID}.notifications`; + /** * Special internal structure for tags for signals. This is used * to filter out tags that have internal structures within them. */ export const INTERNAL_IDENTIFIER = '__internal'; export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`; +export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`; export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`; +export const INTERNAL_NOTIFICATION_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_id`; +export const INTERNAL_NOTIFICATION_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_rule_id`; /** * Detection engine routes @@ -74,6 +82,7 @@ export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE export const TIMELINE_URL = '/api/timeline'; export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; +export const TIMELINE_IMPORT_URL = `${TIMELINE_URL}/_import`; /** * Default signals index key for kibana.dev.yml @@ -87,3 +96,20 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR * Common naming convention for an unauthenticated user */ export const UNAUTHENTICATED_USER = 'Unauthenticated'; + +/* + Licensing requirements + */ +export const MINIMUM_ML_LICENSE = 'platinum'; + +/* + Rule notifications options +*/ +export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ + '.email', + '.slack', + '.pagerduty', + '.webhook', +]; +export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; +export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts similarity index 83% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts index c1c17d2c70836..aeb4d53933022 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts +++ b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../../plugins/alerting/common'; -import { RuleAlertAction } from '../types'; +import { AlertAction } from '../../../../../plugins/alerting/common'; +import { RuleAlertAction } from './types'; export const transformRuleToAlertAction = ({ group, diff --git a/x-pack/legacy/plugins/siem/common/detection_engine/types.ts b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts new file mode 100644 index 0000000000000..0de370b11cdaf --- /dev/null +++ b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertAction } from '../../../../../plugins/alerting/common'; + +export type RuleAlertAction = Omit & { + action_type_id: string; +}; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts index ce73fe1b7c2a5..70e4fb052e172 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts @@ -13,10 +13,10 @@ import { ABOUT_SEVERITY, ABOUT_STEP, ABOUT_TAGS, - ABOUT_TIMELINE, ABOUT_URLS, DEFINITION_CUSTOM_QUERY, DEFINITION_INDEX_PATTERNS, + DEFINITION_TIMELINE, DEFINITION_STEP, RULE_NAME_HEADER, SCHEDULE_LOOPBACK, @@ -170,10 +170,6 @@ describe('Signal detection rules', () => { .eq(ABOUT_RISK) .invoke('text') .should('eql', newRule.riskScore); - cy.get(ABOUT_STEP) - .eq(ABOUT_TIMELINE) - .invoke('text') - .should('eql', 'Default blank timeline'); cy.get(ABOUT_STEP) .eq(ABOUT_URLS) .invoke('text') @@ -202,6 +198,10 @@ describe('Signal detection rules', () => { .eq(DEFINITION_CUSTOM_QUERY) .invoke('text') .should('eql', `${newRule.customQuery} `); + cy.get(DEFINITION_STEP) + .eq(DEFINITION_TIMELINE) + .invoke('text') + .should('eql', 'Default blank timeline'); cy.get(SCHEDULE_STEP) .eq(SCHEDULE_RUNS) diff --git a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts index 1ac9278c3ce1c..a61dc3fe61814 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts @@ -20,7 +20,9 @@ export const CREATE_AND_ACTIVATE_BTN = '[data-test-subj="create-activate"]'; export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; -export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="continue"]'; +export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; + +export const SCHEDULE_CONTINUE_BUTTON = '[data-test-subj="schedule-continue"]'; export const FALSE_POSITIVES_INPUT = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] input'; @@ -43,7 +45,8 @@ export const RULE_DESCRIPTION_INPUT = export const RULE_NAME_INPUT = '[data-test-subj="detectionEngineStepAboutRuleName"] [data-test-subj="input"]'; -export const SEVERITY_DROPDOWN = '[data-test-subj="select"]'; +export const SEVERITY_DROPDOWN = + '[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]'; export const TAGS_INPUT = '[data-test-subj="detectionEngineStepAboutRuleTags"] [data-test-subj="comboBoxSearchInput"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts index 6c16735ba5f24..06e535b37708c 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ABOUT_FALSE_POSITIVES = 4; +export const ABOUT_FALSE_POSITIVES = 3; -export const ABOUT_MITRE = 5; +export const ABOUT_MITRE = 4; export const ABOUT_RULE_DESCRIPTION = '[data-test-subj=stepAboutRuleDetailsToggleDescriptionText]'; @@ -16,14 +16,14 @@ export const ABOUT_SEVERITY = 0; export const ABOUT_STEP = '[data-test-subj="aboutRule"] .euiDescriptionList__description'; -export const ABOUT_TAGS = 6; +export const ABOUT_TAGS = 5; -export const ABOUT_TIMELINE = 2; - -export const ABOUT_URLS = 3; +export const ABOUT_URLS = 2; export const DEFINITION_CUSTOM_QUERY = 1; +export const DEFINITION_TIMELINE = 3; + export const DEFINITION_INDEX_PATTERNS = '[data-test-subj=definitionRule] [data-test-subj="listItemColumnStepRuleDescription"] .euiDescriptionList__description .euiBadge__text'; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts index 6bd5e0887e2fc..ccaa065754b5b 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts @@ -21,11 +21,13 @@ import { REFERENCE_URLS_INPUT, RULE_DESCRIPTION_INPUT, RULE_NAME_INPUT, + SCHEDULE_CONTINUE_BUTTON, SEVERITY_DROPDOWN, TAGS_INPUT, } from '../screens/create_new_rule'; export const createAndActivateRule = () => { + cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); cy.get(CREATE_AND_ACTIVATE_BTN).click({ force: true }); cy.get(CREATE_AND_ACTIVATE_BTN).should('not.exist'); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap similarity index 75% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap index 6b5ea2c5390f1..6503dd8dfb508 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ImportRuleModal renders correctly against snapshot 1`] = ` +exports[`ImportDataModal renders correctly against snapshot 1`] = ` - Import rule + title @@ -17,7 +17,7 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` size="s" >

- Select a SIEM rule (as exported from the Detection Engine UI) to import + description

@@ -39,9 +39,9 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` checked={false} compressed={false} disabled={false} - id="rule-overwrite-saved-object" + id="import-data-modal-checkbox-label" indeterminate={false} - label="Automatically overwrite saved objects with the same rule ID" + label="checkBoxLabel" onChange={[Function]} />
@@ -56,7 +56,7 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` fill={true} onClick={[Function]} > - Import rule + submitBtnText
diff --git a/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx new file mode 100644 index 0000000000000..85dcf9eeb3e5e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { ImportDataModalComponent } from './index'; +jest.mock('../../lib/kibana'); + +describe('ImportDataModal', () => { + test('renders correctly against snapshot', () => { + const wrapper = shallow( + 'successMessage')} + title="title" + /> + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx similarity index 65% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx index 49a181a1cd897..503710f1ee8aa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx @@ -21,29 +21,49 @@ import { } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; -import { importRules } from '../../../../../containers/detection_engine/rules'; +import { ImportRulesResponse, ImportRulesProps } from '../../containers/detection_engine/rules'; import { displayErrorToast, displaySuccessToast, useStateToaster, errorToToaster, -} from '../../../../../components/toasters'; +} from '../toasters'; import * as i18n from './translations'; -interface ImportRuleModalProps { - showModal: boolean; +interface ImportDataModalProps { + checkBoxLabel: string; closeModal: () => void; + description: string; + errorMessage: string; + failedDetailed: (id: string, statusCode: number, message: string) => string; importComplete: () => void; + importData: (arg: ImportRulesProps) => Promise; + showCheckBox: boolean; + showModal: boolean; + submitBtnText: string; + subtitle: string; + successMessage: (totalCount: number) => string; + title: string; } /** * Modal component for importing Rules from a json file */ -export const ImportRuleModalComponent = ({ - showModal, +export const ImportDataModalComponent = ({ + checkBoxLabel, closeModal, + description, + errorMessage, + failedDetailed, importComplete, -}: ImportRuleModalProps) => { + importData, + showCheckBox = true, + showModal, + submitBtnText, + subtitle, + successMessage, + title, +}: ImportDataModalProps) => { const [selectedFiles, setSelectedFiles] = useState(null); const [isImporting, setIsImporting] = useState(false); const [overwrite, setOverwrite] = useState(false); @@ -61,7 +81,7 @@ export const ImportRuleModalComponent = ({ const abortCtrl = new AbortController(); try { - const importResponse = await importRules({ + const importResponse = await importData({ fileToImport: selectedFiles[0], overwrite, signal: abortCtrl.signal, @@ -70,23 +90,20 @@ export const ImportRuleModalComponent = ({ // TODO: Improve error toast details for better debugging failed imports // e.g. When success == true && success_count === 0 that means no rules were overwritten, etc if (importResponse.success) { - displaySuccessToast( - i18n.SUCCESSFULLY_IMPORTED_RULES(importResponse.success_count), - dispatchToaster - ); + displaySuccessToast(successMessage(importResponse.success_count), dispatchToaster); } if (importResponse.errors.length > 0) { const formattedErrors = importResponse.errors.map(e => - i18n.IMPORT_FAILED_DETAILED(e.rule_id, e.error.status_code, e.error.message) + failedDetailed(e.rule_id, e.error.status_code, e.error.message) ); - displayErrorToast(i18n.IMPORT_FAILED, formattedErrors, dispatchToaster); + displayErrorToast(errorMessage, formattedErrors, dispatchToaster); } importComplete(); cleanupAndCloseModal(); } catch (error) { cleanupAndCloseModal(); - errorToToaster({ title: i18n.IMPORT_FAILED, error, dispatchToaster }); + errorToToaster({ title: errorMessage, error, dispatchToaster }); } } }, [selectedFiles, overwrite]); @@ -102,18 +119,18 @@ export const ImportRuleModalComponent = ({ - {i18n.IMPORT_RULE} + {title} -

{i18n.SELECT_RULE}

+

{description}

{ setSelectedFiles(files && files.length > 0 ? files : null); }} @@ -122,12 +139,14 @@ export const ImportRuleModalComponent = ({ isLoading={isImporting} /> - setOverwrite(!overwrite)} - /> + {showCheckBox && ( + setOverwrite(!overwrite)} + /> + )}
@@ -137,7 +156,7 @@ export const ImportRuleModalComponent = ({ disabled={selectedFiles == null || isImporting} fill > - {i18n.IMPORT_RULE} + {submitBtnText}
@@ -147,8 +166,8 @@ export const ImportRuleModalComponent = ({ ); }; -ImportRuleModalComponent.displayName = 'ImportRuleModalComponent'; +ImportDataModalComponent.displayName = 'ImportDataModalComponent'; -export const ImportRuleModal = React.memo(ImportRuleModalComponent); +export const ImportDataModal = React.memo(ImportDataModalComponent); -ImportRuleModal.displayName = 'ImportRuleModal'; +ImportDataModal.displayName = 'ImportDataModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts b/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts new file mode 100644 index 0000000000000..3fe8f2e3ee4bb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const CANCEL_BUTTON = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle', + { + defaultMessage: 'Cancel', + } +); diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 3056b166c1153..6ec15b55ba83d 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { appendSearch } from './helpers'; import { RedirectWrapper } from './redirect_wrapper'; import { SiemPageName } from '../../pages/home/types'; @@ -30,7 +31,14 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; -export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; -export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; -export const getConfigureCasesUrl = () => `${baseCaseUrl}/configure`; +export const getCaseUrl = (search: string | null) => + `${baseCaseUrl}${appendSearch(search ?? undefined)}`; + +export const getCaseDetailsUrl = ({ id, search }: { id: string; search: string | null }) => + `${baseCaseUrl}/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; + +export const getCreateCaseUrl = (search: string | null) => + `${baseCaseUrl}/create${appendSearch(search ?? undefined)}`; + +export const getConfigureCasesUrl = (search: string) => + `${baseCaseUrl}/configure${appendSearch(search ?? undefined)}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 04de0b1d5d3bf..14dc5e7999a65 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -26,6 +26,8 @@ import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; import { ExternalLinkIcon } from '../external_link_icon'; +import { navTabs } from '../../pages/home/home_navigations'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -89,20 +91,25 @@ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ children, detailName, -}) => ( - - {children ? children : detailName} - -); +}) => { + const search = useGetUrlSearch(navTabs.case); + + return ( + + {children ? children : detailName} + + ); +}; export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; -export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( - {children} -)); +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { + const search = useGetUrlSearch(navTabs.case); + return {children}; +}); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx index de662c162fc0a..89af9202a597e 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx @@ -126,6 +126,17 @@ describe('Markdown', () => { ).toHaveProperty('href', 'https://google.com/'); }); + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount(); + + expect( + wrapper + .find('[data-test-subj="markdown-link"]') + .first() + .getDOMNode() + ).not.toHaveProperty('href'); + }); + test('it opens links in a new tab via target="_blank"', () => { const wrapper = mount(); diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx index 30695c9d0c7e2..1368c13619d6b 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx @@ -26,51 +26,53 @@ const REL_NOFOLLOW = 'nofollow'; /** prevents the browser from sending the current address as referrer via the Referer HTTP header */ const REL_NOREFERRER = 'noreferrer'; -export const Markdown = React.memo<{ raw?: string; size?: 'xs' | 's' | 'm' }>( - ({ raw, size = 's' }) => { - const markdownRenderers = { - root: ({ children }: { children: React.ReactNode[] }) => ( - +export const Markdown = React.memo<{ + disableLinks?: boolean; + raw?: string; + size?: 'xs' | 's' | 'm'; +}>(({ disableLinks = false, raw, size = 's' }) => { + const markdownRenderers = { + root: ({ children }: { children: React.ReactNode[] }) => ( + + {children} + + ), + table: ({ children }: { children: React.ReactNode[] }) => ( + + {children} +
+ ), + tableHead: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableRow: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableCell: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( + + {children} -
- ), - table: ({ children }: { children: React.ReactNode[] }) => ( - - {children} -
- ), - tableHead: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableRow: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableCell: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( - - - {children} - - - ), - }; + + + ), + }; - return ( - - ); - } -); + return ( + + ); +}); Markdown.displayName = 'Markdown'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts index e25fb4374bb14..155f63145ca95 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -107,7 +107,22 @@ export const getBreadcrumbsForRoute = ( ]; } if (isCaseRoutes(spyState) && object.navTabs) { - return [...siemRootBreadcrumb, ...getCaseDetailsBreadcrumbs(spyState)]; + const tempNav: SearchNavTab = { urlKey: 'case', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getCaseDetailsBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ) + ), + ]; } if ( spyState != null && diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx index 98eea1eaa6454..cd356212b4400 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer } from '@elastic/eui'; import React from 'react'; import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; @@ -30,12 +29,7 @@ const NewsFeedComponent: React.FC = ({ news }) => ( ) : news.length === 0 ? ( ) : ( - news.map((n: NewsItem) => ( - - - - - )) + news.map((n: NewsItem) => ) )} ); diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx index cb2542a497f08..9cab78c9f20b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx @@ -45,6 +45,7 @@ export const Post = React.memo<{ newsItem: NewsItem }>(({ newsItem }) => {
{description}
+ diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index 6d00edf28a88f..6c2cd21d808b7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -52,7 +52,10 @@ interface OwnProps { } export type OpenTimelineOwnProps = OwnProps & - Pick & + Pick< + OpenTimelineProps, + 'defaultPageSize' | 'title' | 'importCompleteToggle' | 'setImportCompleteToggle' + > & PropsFromRedux; /** Returns a collection of selected timeline ids */ @@ -74,7 +77,9 @@ export const StatefulOpenTimelineComponent = React.memo( defaultPageSize, hideActions = [], isModal = false, + importCompleteToggle, onOpenTimeline, + setImportCompleteToggle, timeline, title, updateTimeline, @@ -264,6 +269,7 @@ export const StatefulOpenTimelineComponent = React.memo( defaultPageSize={defaultPageSize} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + importCompleteToggle={importCompleteToggle} onAddTimelinesToFavorites={undefined} onDeleteSelected={onDeleteSelected} onlyFavorites={onlyFavorites} @@ -278,6 +284,7 @@ export const StatefulOpenTimelineComponent = React.memo( query={search} refetch={refetch} searchResults={timelines} + setImportCompleteToggle={setImportCompleteToggle} selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index b1b100349eb86..8b3da4427a362 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -12,8 +12,10 @@ import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; - +import { ImportDataModal } from '../import_data_modal'; import * as i18n from './translations'; +import { importTimelines } from '../../containers/timeline/all/api'; + import { UtilityBarGroup, UtilityBarText, @@ -31,6 +33,7 @@ export const OpenTimeline = React.memo( defaultPageSize, isLoading, itemIdToExpandedNotesRowMap, + importCompleteToggle, onAddTimelinesToFavorites, onDeleteSelected, onlyFavorites, @@ -47,6 +50,7 @@ export const OpenTimeline = React.memo( searchResults, selectedItems, sortDirection, + setImportCompleteToggle, sortField, title, totalSearchResultsCount, @@ -93,9 +97,25 @@ export const OpenTimeline = React.memo( ); const onRefreshBtnClick = useCallback(() => { - if (typeof refetch === 'function') refetch(); + if (refetch != null) { + refetch(); + } }, [refetch]); + const handleCloseModal = useCallback(() => { + if (setImportCompleteToggle != null) { + setImportCompleteToggle(false); + } + }, [setImportCompleteToggle]); + const handleComplete = useCallback(() => { + if (setImportCompleteToggle != null) { + setImportCompleteToggle(false); + } + if (refetch != null) { + refetch(); + } + }, [setImportCompleteToggle, refetch]); + return ( <> ( onComplete={onCompleteEditTimelineAction} title={actionItem?.title ?? i18n.UNTITLED_TIMELINE} /> + defaultMessage: 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', }); + +export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importTimelineTitle', + { + defaultMessage: 'Import timeline', + } +); + +export const SELECT_TIMELINE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.selectTimelineDescription', + { + defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import', + } +); + +export const INITIAL_PROMPT_TEXT = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.initialPromptTextDescription', + { + defaultMessage: 'Select or drag and drop a valid timelines_export.ndjson file', + } +); + +export const OVERWRITE_WITH_SAME_NAME = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.overwriteDescription', + { + defaultMessage: 'Automatically overwrite saved objects with the same timeline ID', + } +); + +export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) => + i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.successfullyImportedTimelinesTitle', + { + values: { totalCount }, + defaultMessage: + 'Successfully imported {totalCount} {totalCount, plural, =1 {timeline} other {timelines}}', + } + ); + +export const IMPORT_FAILED = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importFailedTitle', + { + defaultMessage: 'Failed to import timelines', + } +); + +export const IMPORT_TIMELINE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importTitle', + { + defaultMessage: 'Import timeline…', + } +); + +export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message: string) => + i18n.translate('xpack.siem.timelines.components.importTimelineModal.importFailedDetailedTitle', { + values: { id, statusCode, message }, + defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}', + }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index b466ea32799d9..1265c056ec506 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -120,6 +120,8 @@ export interface OpenTimelineProps { isLoading: boolean; /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ itemIdToExpandedNotesRowMap: Record; + /** Display import timelines modal*/ + importCompleteToggle?: boolean; /** If this callback is specified, a "Favorite Selected" button will be displayed, and this callback will be invoked when the button is clicked */ onAddTimelinesToFavorites?: OnAddTimelinesToFavorites; /** If this callback is specified, a "Delete Selected" button will be displayed, and this callback will be invoked when the button is clicked */ @@ -144,13 +146,14 @@ export interface OpenTimelineProps { pageSize: number; /** The currently applied search criteria */ query: string; - /** Refetch timelines data */ + /** Refetch table */ refetch?: Refetch; - /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; + /** Toggle export timelines modal*/ + setImportCompleteToggle?: React.Dispatch>; /** the requested sort direction of the query results */ sortDirection: 'asc' | 'desc'; /** the requested field to sort on */ diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap index 85b028cf7cd51..8b7d8efa7ac37 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap @@ -10,14 +10,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "2fe3bdf168af35b9e0ce5dc583bab007c40d47de", - "alternativeNames": Array [ - "*.elastic.co", - "elastic.co", - ], - "commonNames": Array [ - "*.elastic.co", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -27,6 +20,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2021-04-22T12:00:00.000Z", ], + "subjects": Array [ + "*.elastic.co", + ], }, }, Object { @@ -35,13 +31,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "61749734b3246f1584029deb4f5276c64da00ada", - "alternativeNames": Array [ - "api.snapcraft.io", - ], - "commonNames": Array [ - "api.snapcraft.io", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -50,6 +40,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-05-22T12:00:00.000Z", ], + "subjects": Array [ + "api.snapcraft.io", + ], }, }, Object { @@ -58,14 +51,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "6560d3b7dd001c989b85962fa64beb778cdae47a", - "alternativeNames": Array [ - "changelogs.ubuntu.com", - "manpages.ubuntu.com", - ], - "commonNames": Array [ - "changelogs.ubuntu.com", - ], - "issuerNames": Array [ + "issuers": Array [ "Let's Encrypt Authority X3", ], "ja3": Array [ @@ -74,6 +60,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-06-27T01:09:59.000Z", ], + "subjects": Array [ + "changelogs.ubuntu.com", + ], }, }, ] diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 44a538871d951..f95475819abc9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -32,11 +32,11 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, issuerNames }) => + render: ({ _id, issuers }) => getRowItemDraggables({ - rowItems: issuerNames, - attrName: 'tls.server_certificate.issuer.common_name', - idPrefix: `${tableId}-${_id}-table-issuerNames`, + rowItems: issuers, + attrName: 'tls.server.issuer', + idPrefix: `${tableId}-${_id}-table-issuers`, }), }, { @@ -45,18 +45,12 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, alternativeNames, commonNames }) => - alternativeNames != null && alternativeNames.length > 0 - ? getRowItemDraggables({ - rowItems: alternativeNames, - attrName: 'tls.server_certificate.alternative_names', - idPrefix: `${tableId}-${_id}-table-alternative-name`, - }) - : getRowItemDraggables({ - rowItems: commonNames, - attrName: 'tls.server_certificate.subject.common_name', - idPrefix: `${tableId}-${_id}-table-common-name`, - }), + render: ({ _id, subjects }) => + getRowItemDraggables({ + rowItems: subjects, + attrName: 'tls.server.subject', + idPrefix: `${tableId}-${_id}-table-subjects`, + }), }, { field: 'node._id', diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts index 77148bf50c038..453bd8fc84dfa 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts @@ -12,10 +12,9 @@ export const mockTlsData: TlsData = { { node: { _id: '2fe3bdf168af35b9e0ce5dc583bab007c40d47de', - alternativeNames: ['*.elastic.co', 'elastic.co'], - commonNames: ['*.elastic.co'], + subjects: ['*.elastic.co'], ja3: ['7851693188210d3b271aa1713d8c68c2', 'fb4726d465c5f28b84cd6d14cedd13a7'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2021-04-22T12:00:00.000Z'], }, cursor: { @@ -25,10 +24,9 @@ export const mockTlsData: TlsData = { { node: { _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], + subjects: ['api.snapcraft.io'], ja3: ['839868ad711dc55bde0d37a87f14740d'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2019-05-22T12:00:00.000Z'], }, cursor: { @@ -38,10 +36,9 @@ export const mockTlsData: TlsData = { { node: { _id: '6560d3b7dd001c989b85962fa64beb778cdae47a', - alternativeNames: ['changelogs.ubuntu.com', 'manpages.ubuntu.com'], - commonNames: ['changelogs.ubuntu.com'], + subjects: ['changelogs.ubuntu.com'], ja3: ['da12c94da8021bbaf502907ad086e7bc'], - issuerNames: ["Let's Encrypt Authority X3"], + issuers: ["Let's Encrypt Authority X3"], notAfter: ['2019-06-27T01:09:59.000Z'], }, cursor: { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts index 89d0f58684cbe..ff714204144ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts @@ -16,7 +16,7 @@ export const TRANSPORT_LAYER_SECURITY = i18n.translate( export const UNIT = (totalCount: number) => i18n.translate('xpack.siem.network.ipDetails.tlsTable.unit', { values: { totalCount }, - defaultMessage: `{totalCount, plural, =1 {issuer} other {issuers}}`, + defaultMessage: `{totalCount, plural, =1 {server certificate} other {server certificates}}`, }); // Columns diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx new file mode 100644 index 0000000000000..edb0b99cbff8b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonGroup, EuiButtonGroupOption } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { FilterMode } from '../types'; + +import * as i18n from '../translations'; + +const MY_RECENTLY_REPORTED_ID = 'myRecentlyReported'; + +const toggleButtonIcons: EuiButtonGroupOption[] = [ + { + id: 'recentlyCreated', + label: i18n.RECENTLY_CREATED_CASES, + iconType: 'folderExclamation', + }, + { + id: MY_RECENTLY_REPORTED_ID, + label: i18n.MY_RECENTLY_REPORTED_CASES, + iconType: 'reporter', + }, +]; + +export const Filters = React.memo<{ + filterBy: FilterMode; + setFilterBy: (filterBy: FilterMode) => void; + showMyRecentlyReported: boolean; +}>(({ filterBy, setFilterBy, showMyRecentlyReported }) => { + const options = useMemo( + () => + showMyRecentlyReported + ? toggleButtonIcons + : toggleButtonIcons.filter(x => x.id !== MY_RECENTLY_REPORTED_ID), + [showMyRecentlyReported] + ); + const onChange = useCallback( + (filterMode: string) => { + setFilterBy(filterMode as FilterMode); + }, + [setFilterBy] + ); + + return ; +}); + +Filters.displayName = 'Filters'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx new file mode 100644 index 0000000000000..07246c6c6ec88 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx @@ -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 { EuiHorizontalRule, EuiLink, EuiText } from '@elastic/eui'; +import React, { useEffect, useMemo, useRef } from 'react'; + +import { FilterOptions, QueryParams } from '../../containers/case/types'; +import { DEFAULT_QUERY_PARAMS, useGetCases } from '../../containers/case/use_get_cases'; +import { getCaseUrl } from '../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; +import { navTabs } from '../../pages/home/home_navigations'; + +import { NoCases } from './no_cases'; +import { RecentCases } from './recent_cases'; +import * as i18n from './translations'; + +const usePrevious = (value: FilterOptions) => { + const ref = useRef(); + useEffect(() => { + (ref.current as unknown) = value; + }); + return ref.current; +}; + +const MAX_CASES_TO_SHOW = 3; + +const queryParams: QueryParams = { + ...DEFAULT_QUERY_PARAMS, + perPage: MAX_CASES_TO_SHOW, +}; + +const StatefulRecentCasesComponent = React.memo( + ({ filterOptions }: { filterOptions: FilterOptions }) => { + const previousFilterOptions = usePrevious(filterOptions); + const { data, loading, setFilters } = useGetCases(queryParams); + const isLoadingCases = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + const search = useGetUrlSearch(navTabs.case); + const allCasesLink = useMemo( + () => {i18n.VIEW_ALL_CASES}, + [search] + ); + + useEffect(() => { + if (previousFilterOptions !== undefined && previousFilterOptions !== filterOptions) { + setFilters(filterOptions); + } + }, [previousFilterOptions, filterOptions, setFilters]); + + const content = useMemo( + () => + isLoadingCases ? ( + + ) : !isLoadingCases && data.cases.length === 0 ? ( + + ) : ( + + ), + [isLoadingCases, data] + ); + + return ( + + {content} + + {allCasesLink} + + ); + } +); + +StatefulRecentCasesComponent.displayName = 'StatefulRecentCasesComponent'; + +export const StatefulRecentCases = React.memo(StatefulRecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx new file mode 100644 index 0000000000000..9f0361311b7b6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.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 { EuiLink } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { getCreateCaseUrl } from '../../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../../navigation/use_get_url_search'; +import { navTabs } from '../../../pages/home/home_navigations'; + +import * as i18n from '../translations'; + +const NoCasesComponent = () => { + const urlSearch = useGetUrlSearch(navTabs.case); + const newCaseLink = useMemo( + () => {` ${i18n.START_A_NEW_CASE}`}, + [urlSearch] + ); + + return ( + <> + {i18n.NO_CASES} + {newCaseLink} + {'!'} + + ); +}; + +NoCasesComponent.displayName = 'NoCasesComponent'; + +export const NoCases = React.memo(NoCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx new file mode 100644 index 0000000000000..eb17c75f4111b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { Case } from '../../containers/case/types'; +import { getCaseDetailsUrl } from '../link_to/redirect_to_case'; +import { Markdown } from '../markdown'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { navTabs } from '../../pages/home/home_navigations'; +import { IconWithCount } from '../recent_timelines/counts'; + +import * as i18n from './translations'; + +const MarkdownContainer = styled.div` + max-height: 150px; + overflow-y: auto; + width: 300px; +`; + +const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { + const search = useGetUrlSearch(navTabs.case); + + return ( + <> + {cases.map((c, i) => ( + + + + {c.title} + + + + {c.description && c.description.length && ( + + + + + + )} + {i !== cases.length - 1 && } + + + ))} + + ); +}; + +RecentCasesComponent.displayName = 'RecentCasesComponent'; + +export const RecentCases = React.memo(RecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts new file mode 100644 index 0000000000000..d2318e5db88c3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const COMMENTS = i18n.translate('xpack.siem.recentCases.commentsTooltip', { + defaultMessage: 'Comments', +}); + +export const MY_RECENTLY_REPORTED_CASES = i18n.translate( + 'xpack.siem.overview.myRecentlyReportedCasesButtonLabel', + { + defaultMessage: 'My recently reported cases', + } +); + +export const NO_CASES = i18n.translate('xpack.siem.recentCases.noCasesMessage', { + defaultMessage: 'No cases have been created yet. Put your detective hat on and', +}); + +export const RECENTLY_CREATED_CASES = i18n.translate( + 'xpack.siem.overview.recentlyCreatedCasesButtonLabel', + { + defaultMessage: 'Recently created cases', + } +); + +export const START_A_NEW_CASE = i18n.translate('xpack.siem.recentCases.startNewCaseLink', { + defaultMessage: 'start a new case', +}); + +export const VIEW_ALL_CASES = i18n.translate('xpack.siem.recentCases.viewAllCasesLink', { + defaultMessage: 'View all cases', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts new file mode 100644 index 0000000000000..29c7072ce0be6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.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 type FilterMode = 'recentlyCreated' | 'myRecentlyReported'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx index e04b6319cfb24..c80530b245cf3 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx @@ -21,7 +21,7 @@ const FlexGroup = styled(EuiFlexGroup)` margin-right: 16px; `; -const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( +export const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( ({ count, icon, tooltip }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx index de8a3de8094d0..d7271197b9cea 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx @@ -9,15 +9,17 @@ import React from 'react'; import { FilterMode } from '../types'; +import * as i18n from '../translations'; + const toggleButtonIcons: EuiButtonGroupOption[] = [ { id: 'favorites', - label: 'Favorites', + label: i18n.FAVORITES, iconType: 'starFilled', }, { id: `recently-updated`, - label: 'Last updated', + label: i18n.LAST_UPDATED, iconType: 'documentEdit', }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx index 007665b47dedb..5b851701b973c 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx @@ -31,6 +31,8 @@ interface OwnProps { export type Props = OwnProps & PropsFromRedux; +const PAGE_SIZE = 3; + const StatefulRecentTimelinesComponent = React.memo( ({ apolloClient, filterBy, updateIsLoading, updateTimeline }) => { const onOpenTimeline: OnOpenTimeline = useCallback( @@ -53,12 +55,18 @@ const StatefulRecentTimelinesComponent = React.memo( () => {i18n.VIEW_ALL_TIMELINES}, [urlSearch] ); + const loadingPlaceholders = useMemo( + () => ( + + ), + [filterBy] + ); return ( ( {({ timelines, loading }) => ( <> {loading ? ( - + loadingPlaceholders ) : ( {t.description && t.description.length && ( - <> - - - {t.description} - - + + {t.description} + )} diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts index e547272fde6e1..f5934aa317242 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts @@ -13,6 +13,10 @@ export const ERROR_RETRIEVING_USER_DETAILS = i18n.translate( } ); +export const FAVORITES = i18n.translate('xpack.siem.recentTimelines.favoritesButtonLabel', { + defaultMessage: 'Favorites', +}); + export const NO_FAVORITE_TIMELINES = i18n.translate( 'xpack.siem.recentTimelines.noFavoriteTimelinesMessage', { @@ -21,6 +25,10 @@ export const NO_FAVORITE_TIMELINES = i18n.translate( } ); +export const LAST_UPDATED = i18n.translate('xpack.siem.recentTimelines.lastUpdatedButtonLabel', { + defaultMessage: 'Last updated', +}); + export const NO_TIMELINES = i18n.translate('xpack.siem.recentTimelines.noTimelinesMessage', { defaultMessage: "You haven't created any timelines yet. Get out there and start threat hunting!", }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 10aa388449d91..4d2a717153894 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -157,9 +157,7 @@ describe('UrlStateContainer', () => { ).toEqual({ hash: '', pathname: examplePath, - search: [CONSTANTS.timelinePage].includes(page) - ? '?timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))' - : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, + search: `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); } diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index 2cb1b0c96ad79..9d8a4a8e6a908 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,8 +60,20 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - timeline: [CONSTANTS.timeline, CONSTANTS.timerange], - case: [], + timeline: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timerange, + CONSTANTS.timeline, + ], + case: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timerange, + CONSTANTS.timeline, + ], }; export type LocationTypes = diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 5ba1f010e0d52..16ee294224bb9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -13,9 +13,15 @@ import { CommentRequest, CommentResponse, User, + CaseUserActionsResponse, + CaseExternalServiceRequest, + ServiceConnectorCaseParams, + ServiceConnectorCaseResponse, + ActionTypeExecutorResult, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { + ActionLicense, AllCases, BulkUpdateStatus, Case, @@ -23,16 +29,20 @@ import { Comment, FetchCasesProps, SortFieldCase, + CaseUserActions, } from './types'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel, + convertArrayToCamelCase, decodeCaseResponse, decodeCasesResponse, decodeCasesFindResponse, decodeCasesStatusResponse, decodeCommentResponse, + decodeCaseUserActionsResponse, + decodeServiceConnectorCaseResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -71,6 +81,20 @@ export const getReporters = async (signal: AbortSignal): Promise => { return response ?? []; }; +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/user_actions`, + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + export const getCases = async ({ filterOptions = { search: '', @@ -161,3 +185,43 @@ export const deleteCases = async (caseIds: string[]): Promise => { }); return response === 'true' ? true : false; }; + +export const pushCase = async ( + caseId: string, + push: CaseExternalServiceRequest, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/_push`, + { + method: 'POST', + body: JSON.stringify(push), + signal, + } + ); + return convertToCamelCase(decodeCaseResponse(response)); +}; + +export const pushToService = async ( + connectorId: string, + casePushParams: ServiceConnectorCaseParams, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `/api/action/${connectorId}/_execute`, + { + method: 'POST', + body: JSON.stringify({ params: casePushParams }), + signal, + } + ); + return decodeServiceConnectorCaseResponse(response.data); +}; + +export const getActionLicense = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(`/api/action/types`, { + method: 'GET', + signal, + }); + return response; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts index fc7aaa3643d77..d69c23fe02ec9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts @@ -26,6 +26,7 @@ export interface CaseConfigure { createdAt: string; createdBy: ElasticUser; connectorId: string; + connectorName: string; closureType: ClosureType; updatedAt: string; updatedBy: ElasticUser; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 22ac54093d1dc..a24f8303824c5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -13,6 +13,7 @@ import { ClosureType } from './types'; interface PersistCaseConfigure { connectorId: string; + connectorName: string; closureType: ClosureType; } @@ -24,12 +25,12 @@ export interface ReturnUseCaseConfigure { } interface UseCaseConfigure { - setConnectorId: (newConnectorId: string) => void; - setClosureType: (newClosureType: ClosureType) => void; + setConnector: (newConnectorId: string, newConnectorName?: string) => void; + setClosureType?: (newClosureType: ClosureType) => void; } export const useCaseConfigure = ({ - setConnectorId, + setConnector, setClosureType, }: UseCaseConfigure): ReturnUseCaseConfigure => { const [, dispatchToaster] = useStateToaster(); @@ -48,8 +49,10 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); if (res != null) { - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId, res.connectorName); + if (setClosureType != null) { + setClosureType(res.closureType); + } setVersion(res.version); } } @@ -74,7 +77,7 @@ export const useCaseConfigure = ({ }, []); const persistCaseConfigure = useCallback( - async ({ connectorId, closureType }: PersistCaseConfigure) => { + async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { @@ -83,7 +86,11 @@ export const useCaseConfigure = ({ const res = version.length === 0 ? await postCaseConfigure( - { connector_id: connectorId, closure_type: closureType }, + { + connector_id: connectorId, + connector_name: connectorName, + closure_type: closureType, + }, abortCtrl.signal ) : await patchCaseConfigure( @@ -92,8 +99,10 @@ export const useCaseConfigure = ({ ); if (!didCancel) { setPersistLoading(false); - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId); + if (setClosureType) { + setClosureType(res.closureType); + } setVersion(res.version); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts index 0c8b896e2b426..601db373f041e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -16,3 +16,10 @@ export const TAG_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to fetch Tags', } ); + +export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate( + 'xpack.siem.containers.case.pushToExterService', + { + defaultMessage: 'Successfully sent to ServiceNow', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 44519031e91cb..bbbb13788d53a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,30 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User } from '../../../../../../plugins/case/common/api'; +import { User, UserActionField, UserAction } from '../../../../../../plugins/case/common/api'; export interface Comment { id: string; createdAt: string; createdBy: ElasticUser; comment: string; + pushedAt: string | null; + pushedBy: string | null; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; } +export interface CaseUserActions { + actionId: string; + actionField: UserActionField; + action: UserAction; + actionAt: string; + actionBy: ElasticUser; + caseId: string; + commentId: string | null; + newValue: string | null; + oldValue: string | null; +} +export interface CaseExternalService { + pushedAt: string; + pushedBy: string; + connectorId: string; + connectorName: string; + externalId: string; + externalTitle: string; + externalUrl: string; +} export interface Case { id: string; closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - commentIds: string[]; createdAt: string; createdBy: ElasticUser; description: string; + externalService: CaseExternalService | null; status: string; tags: string[]; title: string; + totalComment: number; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; @@ -84,3 +107,10 @@ export interface BulkUpdateStatus { id: string; version: string; } +export interface ActionLicense { + id: string; + name: string; + enabled: boolean; + enabledInConfig: boolean; + enabledInLicense: boolean; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx new file mode 100644 index 0000000000000..12f92b2db039b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getActionLicense } from './api'; +import * as i18n from './translations'; +import { ActionLicense } from './types'; + +interface ActionLicenseState { + actionLicense: ActionLicense | null; + isLoading: boolean; + isError: boolean; +} + +const initialData: ActionLicenseState = { + actionLicense: null, + isLoading: true, + isError: false, +}; + +export const useGetActionLicense = (): ActionLicenseState => { + const [actionLicenseState, setActionLicensesState] = useState(initialData); + + const [, dispatchToaster] = useStateToaster(); + + const fetchActionLicense = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setActionLicensesState({ + ...actionLicenseState, + isLoading: true, + }); + try { + const response = await getActionLicense(abortCtrl.signal); + if (!didCancel) { + setActionLicensesState({ + actionLicense: response.find(l => l.id === '.servicenow') ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [actionLicenseState]); + + useEffect(() => { + fetchActionLicense(); + }, []); + return { ...actionLicenseState }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index b70195e2c126f..02b41c9fc720f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -53,14 +53,15 @@ const initialData: Case = { closedBy: null, createdAt: '', comments: [], - commentIds: [], createdBy: { username: '', }, description: '', + externalService: null, status: '', tags: [], title: '', + totalComment: 0, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx new file mode 100644 index 0000000000000..4c278bc038134 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.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 { isEmpty, uniqBy } from 'lodash/fp'; +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getCaseUserActions } from './api'; +import * as i18n from './translations'; +import { CaseUserActions, ElasticUser } from './types'; + +interface CaseUserActionsState { + caseUserActions: CaseUserActions[]; + firstIndexPushToService: number; + hasDataToPush: boolean; + participants: ElasticUser[]; + isLoading: boolean; + isError: boolean; + lastIndexPushToService: number; +} + +const initialData: CaseUserActionsState = { + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: true, + isError: false, + participants: [], +}; + +interface UseGetCaseUserActions extends CaseUserActionsState { + fetchCaseUserActions: (caseId: string) => void; +} + +const getPushedInfo = ( + caseUserActions: CaseUserActions[] +): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { + const firstIndexPushToService = caseUserActions.findIndex( + cua => cua.action === 'push-to-service' + ); + const lastIndexPushToService = caseUserActions + .map(cua => cua.action) + .lastIndexOf('push-to-service'); + + const hasDataToPush = + lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + return { + firstIndexPushToService, + lastIndexPushToService, + hasDataToPush, + }; +}; + +export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { + const [caseUserActionsState, setCaseUserActionsState] = useState( + initialData + ); + + const [, dispatchToaster] = useStateToaster(); + + const fetchCaseUserActions = useCallback( + (thisCaseId: string) => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + try { + const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); + if (!didCancel) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map(cau => cau.actionBy) + : []; + const caseUserActions = !isEmpty(response) ? response.slice(1) : []; + setCaseUserActionsState({ + caseUserActions, + ...getPushedInfo(caseUserActions), + isLoading: false, + isError: false, + participants, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setCaseUserActionsState({ + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: true, + participants: [], + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, + [caseUserActionsState] + ); + + useEffect(() => { + if (!isEmpty(caseId)) { + fetchCaseUserActions(caseId); + } + }, [caseId]); + return { ...caseUserActionsState, fetchCaseUserActions }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 6c4a6ac4fe58a..ae7b8f3c043fa 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -88,6 +88,20 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS } }; +export const DEFAULT_FILTER_OPTIONS: FilterOptions = { + search: '', + reporters: [], + status: 'open', + tags: [], +}; + +export const DEFAULT_QUERY_PARAMS: QueryParams = { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', +}; + const initialData: AllCases = { cases: [], countClosedCases: null, @@ -109,23 +123,14 @@ interface UseGetCases extends UseGetCasesState { setQueryParams: (queryParams: QueryParams) => void; setSelectedCases: (mySelectedCases: Case[]) => void; } -export const useGetCases = (): UseGetCases => { + +export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: { - search: '', - reporters: [], - status: 'open', - tags: [], - }, + filterOptions: DEFAULT_FILTER_OPTIONS, isError: false, loading: [], - queryParams: { - page: DEFAULT_TABLE_ACTIVE_PAGE, - perPage: DEFAULT_TABLE_LIMIT, - sortField: SortFieldCase.createdAt, - sortOrder: 'desc', - }, + queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx new file mode 100644 index 0000000000000..b6fb15f4fa083 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useReducer, useCallback } from 'react'; + +import { + ServiceConnectorCaseResponse, + ServiceConnectorCaseParams, +} from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster, displaySuccessToast } from '../../components/toasters'; + +import { getCase, pushToService, pushCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; + +interface PushToServiceState { + serviceData: ServiceConnectorCaseResponse | null; + pushedCaseData: Case | null; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } + | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } + | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS_PUSH_SERVICE': + return { + ...state, + isLoading: false, + isError: false, + serviceData: action.payload ?? null, + }; + case 'FETCH_SUCCESS_PUSH_CASE': + return { + ...state, + isLoading: false, + isError: false, + pushedCaseData: action.payload ?? null, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +interface PushToServiceRequest { + caseId: string; + connectorId: string; + connectorName: string; + updateCase: (newCase: Case) => void; +} + +interface UsePostPushToService extends PushToServiceState { + postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; +} + +export const usePostPushToService = (): UsePostPushToService => { + const [state, dispatch] = useReducer(dataFetchReducer, { + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const postPushToService = useCallback( + async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + let cancel = false; + const abortCtrl = new AbortController(); + try { + dispatch({ type: 'FETCH_INIT' }); + const casePushData = await getCase(caseId); + const responseService = await pushToService( + connectorId, + formatServiceRequestData(casePushData), + abortCtrl.signal + ); + const responseCase = await pushCase( + caseId, + { + connector_id: connectorId, + connector_name: connectorName, + external_id: responseService.incidentId, + external_title: responseService.number, + external_url: responseService.url, + }, + abortCtrl.signal + ); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); + dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); + updateCase(responseCase); + displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + return () => { + cancel = true; + abortCtrl.abort(); + }; + }, + [] + ); + + return { ...state, postPushToService }; +}; + +const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { + const { + id: caseId, + createdAt, + createdBy, + comments, + description, + externalService, + title, + updatedAt, + updatedBy, + } = myCase; + + return { + caseId, + createdAt, + createdBy: { + fullName: createdBy.fullName ?? null, + username: createdBy?.username, + }, + comments: comments.map(c => ({ + commentId: c.id, + comment: c.comment, + createdAt: c.createdAt, + createdBy: { + fullName: c.createdBy.fullName ?? null, + username: c.createdBy.username, + }, + updatedAt: c.updatedAt, + updatedBy: + c.updatedBy != null + ? { + fullName: c.updatedBy.fullName ?? null, + username: c.updatedBy.username, + } + : null, + })), + description, + incidentId: externalService?.externalId ?? null, + title, + updatedAt, + updatedBy: + updatedBy != null + ? { + fullName: updatedBy.fullName ?? null, + username: updatedBy.username, + } + : null, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 987620469901b..f8af088f7e03b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -25,6 +25,7 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; updateValue: CaseRequest[UpdateKey]; + fetchCaseUserActions?: (caseId: string) => void; } type Action = @@ -64,6 +65,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; + updateCase: (newCase: Case) => void; } export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -74,8 +76,12 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase }); const [, dispatchToaster] = useStateToaster(); + const updateCase = useCallback((newCase: Case) => { + dispatch({ type: 'FETCH_SUCCESS', payload: newCase }); + }, []); + const dispatchUpdateCaseProperty = useCallback( - async ({ updateKey, updateValue }: UpdateByKey) => { + async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: updateKey }); @@ -85,6 +91,9 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { + if (fetchCaseUserActions != null) { + fetchCaseUserActions(caseId); + } dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { @@ -104,5 +113,5 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase [state] ); - return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; + return { ...state, updateCase, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index a40a1100ca735..c1b2bfde30126 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -70,8 +70,15 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd } }; +interface UpdateComment { + caseId: string; + commentId: string; + commentUpdate: string; + fetchUserActions: () => void; +} + interface UseUpdateComment extends CommentUpdateState { - updateComment: (caseId: string, commentId: string, commentUpdate: string) => void; + updateComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void; addPostedComment: Dispatch; } @@ -84,7 +91,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [, dispatchToaster] = useStateToaster(); const dispatchUpdateComment = useCallback( - async (caseId: string, commentId: string, commentUpdate: string) => { + async ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: commentId }); @@ -98,6 +105,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { currentComment.version ); if (!cancel) { + fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { update: response, commentId } }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8f24d5a435240..ce23ac6c440b6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -23,6 +23,10 @@ import { CommentResponseRt, CasesConfigureResponse, CaseConfigureResponseRt, + CaseUserActionsResponse, + CaseUserActionsResponseRt, + ServiceConnectorCaseResponseRt, + ServiceConnectorCaseResponse, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -86,3 +90,15 @@ export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) = CaseConfigureResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity) ); + +export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) => + pipe( + CaseUserActionsResponseRt.decode(respUserActions), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => + pipe( + ServiceConnectorCaseResponseRt.decode(respPushCase), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index 8fdc6a67f7d71..e8019659d49c6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -39,7 +39,7 @@ describe('Detections Rules API', () => { await addRule({ rule: ruleMock, signal: abortCtrl.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { body: - '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[]}', + '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[],"throttle":null}', method: 'POST', signal: abortCtrl.signal, }); @@ -291,7 +291,7 @@ describe('Detections Rules API', () => { await duplicateRules({ rules: rulesMock.data }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', { body: - '[{"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1},{"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1}]', + '[{"actions":[],"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1},{"actions":[],"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1}]', method: 'POST', }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts index 51526c0ab9949..59782e8a36338 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts @@ -32,9 +32,11 @@ export const ruleMock: NewRule = { to: 'now', type: 'query', threat: [], + throttle: null, }; export const savedRuleMock: Rule = { + actions: [], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -65,6 +67,7 @@ export const savedRuleMock: Rule = { to: 'now', type: 'query', threat: [], + throttle: null, updated_at: 'mm/dd/yyyyTHH:MM:sssz', updated_by: 'mockUser', }; @@ -75,6 +78,7 @@ export const rulesMock: FetchRulesResponse = { total: 2, data: [ { + actions: [], created_at: '2020-02-14T19:49:28.178Z', updated_at: '2020-02-14T19:49:28.320Z', created_by: 'elastic', @@ -103,9 +107,11 @@ export const rulesMock: FetchRulesResponse = { to: 'now', type: 'query', threat: [], + throttle: null, version: 1, }, { + actions: [], created_at: '2020-02-14T19:49:28.189Z', updated_at: '2020-02-14T19:49:28.326Z', created_by: 'elastic', @@ -133,6 +139,7 @@ export const rulesMock: FetchRulesResponse = { to: 'now', type: 'query', threat: [], + throttle: null, version: 1, }, ], 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 c75d7b78cf92f..3ec3e6d2b3036 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 @@ -13,6 +13,19 @@ export const RuleTypeSchema = t.keyof({ }); export type RuleType = t.TypeOf; +/** + * Params is an "record", since it is a type of AlertActionParams which is action templates. + * @see x-pack/plugins/alerting/common/alert.ts + */ +export const action = t.exact( + t.type({ + group: t.string, + id: t.string, + action_type_id: t.string, + params: t.record(t.string, t.any), + }) +); + export const NewRuleSchema = t.intersection([ t.type({ description: t.string, @@ -24,6 +37,7 @@ export const NewRuleSchema = t.intersection([ type: RuleTypeSchema, }), t.partial({ + actions: t.array(action), anomaly_threshold: t.number, created_by: t.string, false_positives: t.array(t.string), @@ -40,6 +54,7 @@ export const NewRuleSchema = t.intersection([ saved_id: t.string, tags: t.array(t.string), threat: t.array(t.unknown), + throttle: t.union([t.string, t.null]), to: t.string, updated_by: t.string, note: t.string, @@ -54,9 +69,15 @@ export interface AddRulesProps { signal: AbortSignal; } -const MetaRule = t.type({ - from: t.string, -}); +const MetaRule = t.intersection([ + t.type({ + from: t.string, + }), + t.partial({ + throttle: t.string, + kibanaSiemAppUrl: t.string, + }), +]); export const RuleSchema = t.intersection([ t.type({ @@ -81,6 +102,8 @@ export const RuleSchema = t.intersection([ threat: t.array(t.unknown), updated_at: t.string, updated_by: t.string, + actions: t.array(action), + throttle: t.union([t.string, t.null]), }), t.partial({ anomaly_threshold: t.number, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx index e0bf2c4907370..ab09f796ad49b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx @@ -31,6 +31,7 @@ describe('useRule', () => { expect(result.current).toEqual([ false, { + actions: [], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -59,6 +60,7 @@ describe('useRule', () => { severity: 'high', tags: ['APM'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: 'mm/dd/yyyyTHH:MM:sssz', diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx index 242d715e20f77..5d13b57f862bc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx @@ -58,6 +58,7 @@ describe('useRules', () => { { data: [ { + actions: [], created_at: '2020-02-14T19:49:28.178Z', created_by: 'elastic', description: @@ -82,6 +83,7 @@ describe('useRules', () => { severity: 'high', tags: ['Elastic', 'Endpoint'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: '2020-02-14T19:49:28.320Z', @@ -89,6 +91,7 @@ describe('useRules', () => { version: 1, }, { + actions: [], created_at: '2020-02-14T19:49:28.189Z', created_by: 'elastic', description: @@ -113,6 +116,7 @@ describe('useRules', () => { severity: 'medium', tags: ['Elastic', 'Endpoint'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: '2020-02-14T19:49:28.326Z', diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts index edda2e30ea400..0479851fc5b55 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts @@ -4,9 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ImportRulesProps, ImportRulesResponse } from '../../detection_engine/rules'; import { KibanaServices } from '../../../lib/kibana'; +import { TIMELINE_IMPORT_URL, TIMELINE_EXPORT_URL } from '../../../../common/constants'; import { ExportSelectedData } from '../../../components/generic_downloader'; -import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const importTimelines = async ({ + fileToImport, + overwrite = false, + signal, +}: ImportRulesProps): Promise => { + const formData = new FormData(); + formData.append('file', fileToImport); + + return KibanaServices.get().http.fetch(`${TIMELINE_IMPORT_URL}`, { + method: 'POST', + headers: { 'Content-Type': undefined }, + query: { overwrite }, + body: formData, + signal, + }); +}; export const exportSelectedTimeline: ExportSelectedData = async ({ excludeExportDetails = false, diff --git a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts index bbb92282bee83..f513a94d69667 100644 --- a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts @@ -33,10 +33,9 @@ export const tlsQuery = gql` edges { node { _id - alternativeNames - commonNames + subjects ja3 - issuerNames + issuers notAfter } cursor { diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 9802a5f5bd3bf..5d43024625d0d 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -9213,22 +9213,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "alternativeNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "notAfter", "description": "", @@ -9246,7 +9230,7 @@ "deprecationReason": null }, { - "name": "commonNames", + "name": "subjects", "description": "", "args": [], "type": { @@ -9278,7 +9262,7 @@ "deprecationReason": null }, { - "name": "issuerNames", + "name": "issuers", "description": "", "args": [], "type": { diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 3528ee6e13a38..a5d1e3fbcba27 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -1859,15 +1859,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -5679,13 +5677,11 @@ export namespace GetTlsQuery { _id: Maybe; - alternativeNames: Maybe; - - commonNames: Maybe; + subjects: Maybe; ja3: Maybe; - issuerNames: Maybe; + issuers: Maybe; notAfter: Maybe; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 0b3b0daaf4bbc..836595c7c45d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,72 +30,79 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; + showLoading?: boolean; } -export const AddComment = React.memo(({ caseId, onCommentPosted }) => { - const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); - const { form } = useForm({ - defaultValue: initialCommentValue, - options: { stripEmptyFields: false }, - schema, - }); - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - form, - 'comment' - ); +export const AddComment = React.memo( + ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); + const { form } = useForm({ + defaultValue: initialCommentValue, + options: { stripEmptyFields: false }, + schema, + }); + const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( + form, + 'comment' + ); - useEffect(() => { - if (commentData !== null) { - onCommentPosted(commentData); - form.reset(); - resetCommentData(); - } - }, [commentData]); + useEffect(() => { + if (commentData !== null) { + onCommentPosted(commentData); + form.reset(); + resetCommentData(); + } + }, [commentData]); - const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); - if (isValid) { - await postComment(data); - } - }, [form]); + const onSubmit = useCallback(async () => { + const { isValid, data } = await form.submit(); + if (isValid) { + if (onCommentSaving != null) { + onCommentSaving(); + } + await postComment(data); + } + }, [form]); - return ( - <> - {isLoading && } -
- - {i18n.ADD_COMMENT} - - ), - topRightContent: ( - - ), - }} - /> - - - ); -}); + return ( + <> + {isLoading && showLoading && } +
+ + {i18n.ADD_COMMENT} + + ), + topRightContent: ( + + ), + }} + /> + + + ); + } +); AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 48fbb4e74c407..d4ec32dfd070b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -18,12 +18,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -34,12 +35,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -50,12 +52,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -66,14 +69,15 @@ export const useGetCasesMockState: UseGetCasesState = { id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: '2020-02-13T19:44:13.328Z', - updatedBy: { username: 'elastic' }, + totalComment: 0, + updatedAt: null, + updatedBy: null, version: 'WzQ3LDFd', }, { @@ -82,12 +86,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Uh oh', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index b9e1113c486ad..32a29483e9c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -35,6 +35,7 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); + export const getCasesColumns = ( actions: Array>, filterStatus: string @@ -108,11 +109,11 @@ export const getCasesColumns = ( }, { align: 'right', - field: 'commentIds', + field: 'totalComment', name: i18n.COMMENTS, sortable: true, - render: (comments: Case['commentIds']) => - renderStringField(`${comments.length}`, `case-table-column-commentCount`), + render: (totalComment: Case['totalComment']) => + renderStringField(`${totalComment}`, `case-table-column-commentCount`), }, filterStatus === 'open' ? { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 13869c79c45fd..bdcb87b483851 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -95,7 +95,9 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); + ).toEqual( + `#/link-to/case/${useGetCasesMockState.data.cases[0].id}?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:!(global),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)))` + ); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index e7e1e624ccba2..cbb9ddae22d04 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -26,6 +26,7 @@ import { useGetCases, UpdateCase } from '../../../../containers/case/use_get_cas import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { Panel } from '../../../../components/panel'; import { UtilityBar, @@ -35,19 +36,16 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; - import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { OpenClosedStats } from '../open_closed_stats'; +import { navTabs } from '../../../home/home_navigations'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; -const CONFIGURE_CASES_URL = getConfigureCasesUrl(); -const CREATE_CASE_URL = getCreateCaseUrl(); - const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; `; @@ -78,6 +76,8 @@ const getSortField = (field: string): SortFieldCase => { return SortFieldCase.createdAt; }; export const AllCases = React.memo(() => { + const urlSearch = useGetUrlSearch(navTabs.case); + const { countClosedCases, countOpenCases, @@ -276,12 +276,12 @@ export const AllCases = React.memo(() => { /> - + {i18n.CONFIGURE_CASES_BUTTON} - + {i18n.CREATE_TITLE} @@ -342,7 +342,12 @@ export const AllCases = React.memo(() => { titleSize="xs" body={i18n.NO_CASES_BODY} actions={ - + {i18n.ADD_NEW_CASE} } diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx index 9dbd71ea3e34c..0420a71fea907 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { EuiBadge, @@ -39,7 +39,7 @@ interface CaseStatusProps { isSelected: boolean; status: string; title: string; - toggleStatusCase: (status: string) => void; + toggleStatusCase: (evt: unknown) => void; value: string | null; } const CaseStatusComp: React.FC = ({ @@ -55,51 +55,46 @@ const CaseStatusComp: React.FC = ({ title, toggleStatusCase, value, -}) => { - const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ - toggleStatusCase, - ]); - return ( - - - - - - {i18n.STATUS} - - - {status} - - - - - {title} - - - - - - - - - - - - +}) => ( + + + + - + {i18n.STATUS} + + + {status} + + + + + {title} + + + - - - ); -}; + + + + + + + + + + + + + +); export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index e11441eac3a9d..7aadea1a453a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -13,7 +13,6 @@ export const caseProps: CaseProps = { closedAt: null, closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ { comment: 'Solve this fast!', @@ -24,6 +23,8 @@ export const caseProps: CaseProps = { username: 'smilovic', email: 'notmyrealemailfool@elastic.co', }, + pushedAt: null, + pushedBy: null, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { username: 'elastic', @@ -34,9 +35,11 @@ export const caseProps: CaseProps = { createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', + totalComment: 1, updatedAt: '2020-02-19T15:02:57.995Z', updatedBy: { username: 'elastic', @@ -44,6 +47,7 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; + export const caseClosedProps: CaseProps = { ...caseProps, initialData: { @@ -63,3 +67,21 @@ export const data: Case = { export const dataClosed: Case = { ...caseClosedProps.initialData, }; + +export const caseUserActions = [ + { + actionField: ['comment'], + action: 'create', + actionAt: '2020-03-20T17:10:09.814Z', + actionBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', + }, + newValue: 'Solve this fast!', + oldValue: null, + actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3f4a83d1bff33..18cc33d8a6d4d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -11,11 +11,18 @@ import { mount } from 'enzyme'; import routeData from 'react-router'; /* eslint-enable @kbn/eslint/module_migration */ import { CaseComponent } from './'; -import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { wait } from '../../../../lib/helpers'; +import { usePushToService } from './push_to_service'; jest.mock('../../../../containers/case/use_update_case'); +jest.mock('../../../../containers/case/use_get_case_user_actions'); +jest.mock('./push_to_service'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; +const usePushToServiceMock = usePushToService as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -47,6 +54,7 @@ const mockLocation = { describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + const fetchCaseUserActions = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -66,13 +74,31 @@ describe('CaseView ', () => { updateCaseProperty, }; + const defaultUseGetCaseUserActions = { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: false, + lastIndexPushToService: -1, + participants: [data.createdBy], + }; + + const defaultUsePushToServiceMock = { + pushButton: <>{'Hello Button'}, + pushCallouts: null, + }; + beforeEach(() => { jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); + usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock); }); - it('should render CaseComponent', () => { + it('should render CaseComponent', async () => { const wrapper = mount( @@ -80,6 +106,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find(`[data-test-subj="case-view-title"]`) @@ -119,7 +146,7 @@ describe('CaseView ', () => { ).toEqual(data.description); }); - it('should show closed indicators in header when case is closed', () => { + it('should show closed indicators in header when case is closed', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, caseData: dataClosed, @@ -131,6 +158,7 @@ describe('CaseView ', () => { ); + await wait(); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); expect( wrapper @@ -146,7 +174,7 @@ describe('CaseView ', () => { ).toEqual(dataClosed.status); }); - it('should dispatch update state when button is toggled', () => { + it('should dispatch update state when button is toggled', async () => { const wrapper = mount( @@ -154,18 +182,19 @@ describe('CaseView ', () => { ); - + await wait(); wrapper .find('input[data-test-subj="toggle-case-status"]') .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ + fetchCaseUserActions, updateKey: 'status', updateValue: 'closed', }); }); - it('should render comments', () => { + it('should render comments', async () => { const wrapper = mount( @@ -173,6 +202,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 0ac3adeb860ff..5c20b53f5fcb9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - +import { + EuiButtonToggle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiLoadingSpinner, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; + import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -18,12 +25,16 @@ import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; +import { navTabs } from '../../../home/home_navigations'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { usePushToService } from './push_to_service'; interface Props { caseId: string; @@ -37,6 +48,13 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; +const MyEuiHorizontalRule = styled(EuiHorizontalRule)` + margin-left: 48px; + &.euiHorizontalRule--full { + width: calc(100% - 48px); + } +`; + export interface CaseProps { caseId: string; initialData: Case; @@ -45,7 +63,22 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; - const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); + const search = useGetUrlSearch(navTabs.case); + + const [initLoadingData, setInitLoadingData] = useState(true); + const { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService, + hasDataToPush, + isLoading: isLoadingUserActions, + lastIndexPushToService, + participants, + } = useGetCaseUserActions(caseId); + const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( + caseId, + initialData + ); // Update Fields const onUpdateField = useCallback( @@ -55,6 +88,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const titleUpdate = getTypedPayload(updateValue); if (titleUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'title', updateValue: titleUpdate, }); @@ -64,6 +98,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'description', updateValue: descriptionUpdate, }); @@ -72,6 +107,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => case 'tags': const tagsUpdate = getTypedPayload(updateValue); updateCaseProperty({ + fetchCaseUserActions, updateKey: 'tags', updateValue: tagsUpdate, }); @@ -80,6 +116,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const statusUpdate = getTypedPayload(updateValue); if (caseData.status !== updateValue) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'status', updateValue: statusUpdate, }); @@ -88,12 +125,29 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [caseData.status] + [fetchCaseUserActions, updateCaseProperty, caseData.status] ); + const handleUpdateCase = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchCaseUserActions(newCase.id); + }, + [updateCase, fetchCaseUserActions] + ); + + const { pushButton, pushCallouts } = usePushToService({ + caseId: caseData.id, + caseStatus: caseData.status, + isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + updateCase: handleUpdateCase, + }); + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); - const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); - + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'closed' : 'open'), + [onUpdateField] + ); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); const caseStatusData = useMemo( @@ -111,7 +165,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } : { 'data-test-subj': 'case-view-closedAt', - value: caseData.closedAt, + value: caseData.closedAt ?? '', title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, @@ -126,14 +180,21 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => subject: i18n.EMAIL_SUBJECT(caseData.title), body: i18n.EMAIL_BODY(caseLink), }), - [caseData.title] + [caseLink, caseData.title] ); + + useEffect(() => { + if (initLoadingData && !isLoadingUserActions) { + setInitLoadingData(false); + } + }, [initLoadingData, isLoadingUserActions]); + return ( <> (({ caseId, initialData }) => + {pushCallouts != null && pushCallouts} - + {initLoadingData && } + {!initLoadingData && ( + <> + + + + + + + {hasDataToPush && {pushButton}} + + + )} + void; +} + +interface Connector { + connectorId: string; + connectorName: string; +} + +interface ReturnUsePushToService { + pushButton: JSX.Element; + pushCallouts: JSX.Element | null; +} + +export const usePushToService = ({ + caseId, + caseStatus, + updateCase, + isNew, +}: UsePushToService): ReturnUsePushToService => { + const urlSearch = useGetUrlSearch(navTabs.case); + const [connector, setConnector] = useState(null); + + const { isLoading, postPushToService } = usePostPushToService(); + + const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => { + setConnector({ connectorId, connectorName: connectorName ?? '' }); + }, []); + + const { loading: loadingCaseConfigure } = useCaseConfigure({ + setConnector: handleSetConnector, + }); + + const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); + + const handlePushToService = useCallback(() => { + if (connector != null) { + postPushToService({ + caseId, + ...connector, + updateCase, + }); + } + }, [caseId, connector, postPushToService, updateCase]); + + const errorsMsg = useMemo(() => { + let errors: Array<{ title: string; description: JSX.Element }> = []; + if (actionLicense != null && !actionLicense.enabledInLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, + description: ( + + {i18n.LINK_CLOUD_DEPLOYMENT} + + ), + }} + /> + ), + }, + ]; + } + if (connector == null && !loadingCaseConfigure && !loadingLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + description: ( + + {i18n.LINK_CONNECTOR_CONFIGURE} + + ), + }} + /> + ), + }, + ]; + } + if (caseStatus === 'closed') { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, + description: ( + + ), + }, + ]; + } + if (actionLicense != null && !actionLicense.enabledInConfig) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, + description: ( + + {'coming soon...'} + + ), + }} + /> + ), + }, + ]; + } + return errors; + }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]); + + const pushToServiceButton = useMemo( + () => ( + 0} + isLoading={isLoading} + > + {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + + ), + [isNew, handlePushToService, isLoading, loadingLicense, loadingCaseConfigure, errorsMsg] + ); + + const objToReturn = useMemo( + () => ({ + pushButton: + errorsMsg.length > 0 ? ( + {errorsMsg[0].description}

} + > + {pushToServiceButton} +
+ ) : ( + <>{pushToServiceButton} + ), + pushCallouts: errorsMsg.length > 0 ? : null, + }), + [errorsMsg, pushToServiceButton] + ); + return objToReturn; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index e5fa3bff51f85..beba80ccd934c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -18,17 +18,40 @@ export const SHOWING_CASES = (actionDate: string, actionName: string, userName: defaultMessage: '{userName} {actionName} on {actionDate}', }); -export const ADDED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.addDescription', +export const ADDED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.addedField', { + defaultMessage: 'added', +}); + +export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.changededField', { + defaultMessage: 'changed', +}); + +export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', { + defaultMessage: 'edited', +}); + +export const REMOVED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.removedField', { + defaultMessage: 'removed', +}); + +export const PUSHED_NEW_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.pushedNewIncident', { - defaultMessage: 'added description', + defaultMessage: 'pushed as new incident', + } +); + +export const UPDATE_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.updateIncident', + { + defaultMessage: 'updated incident', } ); -export const EDITED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.editDescription', +export const ADDED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.addDescription', { - defaultMessage: 'edited description', + defaultMessage: 'added description', } ); @@ -52,6 +75,14 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { defaultMessage: 'Status', }); +export const CASE = i18n.translate('xpack.siem.case.caseView.case', { + defaultMessage: 'case', +}); + +export const COMMENT = i18n.translate('xpack.siem.case.caseView.comment', { + defaultMessage: 'comment', +}); + export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); @@ -71,3 +102,56 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); + +export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { + defaultMessage: 'Push as ServiceNow incident', +}); + +export const UPDATE_PUSH_SERVICENOW = i18n.translate( + 'xpack.siem.case.caseView.updatePushAsServicenowIncident', + { + defaultMessage: 'Update ServiceNow incident', + } +); + +export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', + { + defaultMessage: 'Configure external connector', + } +); + +export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle', + { + defaultMessage: 'Reopen the case', + } +); + +export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle', + { + defaultMessage: 'Enable ServiceNow in Kibana configuration file', + } +); + +export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', + { + defaultMessage: 'Upgrade to Elastic Platinum', + } +); + +export const LINK_CLOUD_DEPLOYMENT = i18n.translate( + 'xpack.siem.case.caseView.cloudDeploymentLink', + { + defaultMessage: 'cloud deployment', + } +); + +export const LINK_CONNECTOR_CONFIGURE = i18n.translate( + 'xpack.siem.case.caseView.connectorConfigureLink', + { + defaultMessage: 'connector', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index c8ef6e32595d0..5f99ec362cd5e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -28,7 +28,8 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ActionConnectorTableItem } from '../../../../../../../../plugins/triggers_actions_ui/public/types'; - +import { getCaseUrl } from '../../../../components/link_to'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { ClosureType, CasesConfigurationMapping, @@ -38,11 +39,9 @@ import { Connectors } from '../configure_cases/connectors'; import { ClosureOptions } from '../configure_cases/closure_options'; import { Mapping } from '../configure_cases/mapping'; import { SectionWrapper } from '../wrappers'; +import { navTabs } from '../../../../pages/home/home_navigations'; import { configureCasesReducer, State } from './reducer'; import * as i18n from './translations'; -import { getCaseUrl } from '../../../../components/link_to'; - -const CASE_URL = getCaseUrl(); const FormWrapper = styled.div` ${({ theme }) => css` @@ -73,6 +72,7 @@ const actionTypes: ActionType[] = [ ]; const ConfigureCasesComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); const { http, triggers_actions_ui, notifications, application } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); @@ -113,7 +113,7 @@ const ConfigureCasesComponent: React.FC = () => { }, []); const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({ - setConnectorId, + setConnector: setConnectorId, setClosureType, }); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); @@ -128,9 +128,13 @@ const ConfigureCasesComponent: React.FC = () => { // TO DO give a warning/error to user when field are not mapped so they have chance to do it () => { setActionBarVisible(false); - persistCaseConfigure({ connectorId, closureType }); + persistCaseConfigure({ + connectorId, + connectorName: connectors.find(c => c.id === connectorId)?.name ?? '', + closureType, + }); }, - [connectorId, closureType, mapping] + [connectorId, connectors, closureType, mapping] ); const onChangeConnector = useCallback((newConnectorId: string) => { @@ -231,7 +235,7 @@ const ConfigureCasesComponent: React.FC = () => { isDisabled={isLoadingAny} isLoading={persistLoading} aria-label="Cancel" - href={CASE_URL} + href={getCaseUrl(search)} > {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx new file mode 100644 index 0000000000000..15b50e4b4cd8d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +interface ErrorsPushServiceCallOut { + errors: Array<{ title: string; description: JSX.Element }>; +} + +const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + <> + + + + {i18n.DISMISS_CALLOUT} + + + + + ) : null; +}; + +export const ErrorsPushServiceCallOut = memo(ErrorsPushServiceCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts new file mode 100644 index 0000000000000..57712e720f6d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.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 { i18n } from '@kbn/i18n'; + +export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.case.errorsPushServiceCallOutTitle', + { + defaultMessage: 'To send cases to external systems, you need to:', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.case.dismissErrorsPushServiceCallOutTitle', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx new file mode 100644 index 0000000000000..008f4d7048f56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; +import React from 'react'; + +import { CaseFullExternalService } from '../../../../../../../../plugins/case/common/api'; +import { CaseUserActions } from '../../../../containers/case/types'; +import * as i18n from '../case_view/translations'; + +interface LabelTitle { + action: CaseUserActions; + field: string; + firstIndexPushToService: number; + index: number; +} + +export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { + if (field === 'tags') { + return getTagsLabelTitle(action); + } else if (field === 'title' && action.action === 'update') { + return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + action.newValue + }"`; + } else if (field === 'description' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; + } else if (field === 'status' && action.action === 'update') { + return `${ + action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase() + } ${i18n.CASE}`; + } else if (field === 'comment' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { + return getPushedServiceLabelTitle(action, firstIndexPushToService, index); + } + return ''; +}; + +const getTagsLabelTitle = (action: CaseUserActions) => ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + +); + +const getPushedServiceLabelTitle = ( + action: CaseUserActions, + firstIndexPushToService: number, + index: number +) => { + const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; + return ( + + + {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + + + + {pushedVal?.connector_name} {pushedVal?.external_title} + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 6a3d319561353..8b77186f76f77 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,27 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + import * as i18n from '../case_view/translations'; -import { Case } from '../../../../containers/case/types'; +import { Case, CaseUserActions, Comment } from '../../../../containers/case/types'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; +import { AddComment } from '../add_comment'; +import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; -import { AddComment } from '../add_comment'; -import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; + caseUserActions: CaseUserActions[]; + fetchUserActions: () => void; + firstIndexPushToService: number; isLoadingDescription: boolean; + isLoadingUserActions: boolean; + lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } -const DescriptionId = 'description'; -const NewId = 'newComment'; +const MyEuiFlexGroup = styled(EuiFlexGroup)` + margin-bottom: 8px; +`; + +const DESCRIPTION_ID = 'description'; +const NEW_ID = 'newComment'; export const UserActionTree = React.memo( - ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + ({ + data: caseData, + caseUserActions, + fetchUserActions, + firstIndexPushToService, + isLoadingDescription, + isLoadingUserActions, + lastIndexPushToService, + onUpdateField, + }: UserActionTreeProps) => { + const { commentId } = useParams(); + const handlerTimeoutId = useRef(0); + const [initLoading, setInitLoading] = useState(true); + const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); @@ -45,20 +72,54 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - updateComment(caseData.id, id, content); + updateComment({ + caseId: caseData.id, + commentId: id, + commentUpdate: content, + fetchUserActions, + }); }, [handleManageMarkdownEditId, updateComment] ); + const handleOutlineComment = useCallback( + (id: string) => { + const moveToTarget = document.getElementById(`${id}-permLink`); + if (moveToTarget != null) { + const yOffset = -60; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + } + window.clearTimeout(handlerTimeoutId.current); + setSelectedOutlineCommentId(id); + handlerTimeoutId.current = window.setTimeout(() => { + setSelectedOutlineCommentId(''); + window.clearTimeout(handlerTimeoutId.current); + }, 2400); + }, + [handlerTimeoutId.current] + ); + + const handleUpdate = useCallback( + (comment: Comment) => { + addPostedComment(comment); + fetchUserActions(); + }, + [addPostedComment, fetchUserActions] + ); + const MarkdownDescription = useMemo( () => ( { - handleManageMarkdownEditId(DescriptionId); - onUpdateField(DescriptionId, content); + handleManageMarkdownEditId(DESCRIPTION_ID); + onUpdateField(DESCRIPTION_ID, content); }} onChangeEditable={handleManageMarkdownEditId} /> @@ -67,55 +128,123 @@ export const UserActionTree = React.memo( ); const MarkdownNewComment = useMemo( - () => , - [caseData.id] + () => ( + + ), + [caseData.id, handleUpdate] ); + useEffect(() => { + if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) { + setInitLoading(false); + if (commentId != null) { + handleOutlineComment(commentId); + } + } + }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]); + return ( <> {i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} + onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} userName={caseData.createdBy.username} /> - {comments.map(comment => ( - + + {caseUserActions.map((action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = comments.find(c => c.id === action.commentId); + if (comment != null) { + return ( + {i18n.ADDED_COMMENT}} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + + } + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + outlineComment={handleOutlineComment} + userName={comment.createdBy.username} + updatedAt={comment.updatedAt} + /> + ); } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - userName={comment.createdBy.username} - /> - ))} + } + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstIndexPushToService, + index, + }); + + return ( + {labelTitle}} + linkId={ + action.action === 'update' && action.commentId != null ? action.commentId : null + } + fullName={action.actionBy.fullName ?? action.actionBy.username} + outlineComment={handleOutlineComment} + showTopFooter={ + action.action === 'push-to-service' && index === lastIndexPushToService + } + showBottomFooter={ + action.action === 'push-to-service' && + index === lastIndexPushToService && + index < caseUserActions.length - 1 + } + userName={action.actionBy.username} + /> + ); + } + return null; + })} + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( + + + + + + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts new file mode 100644 index 0000000000000..0ca6bcff513fc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../case_view/translations'; + +export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.alreadyPushedToService', + { + defaultMessage: 'Already pushed to Service Now incident', + } +); + +export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.requiredUpdateToService', + { + defaultMessage: 'Requires update to ServiceNow incident', + } +); + +export const COPY_LINK_COMMENT = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { + defaultMessage: 'click to copy comment link', +}); + +export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate( + 'xpack.siem.case.caseView.moveToCommentAria', + { + defaultMessage: 'click to highlight the reference comment', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index ca73f200f1793..10a7c56e2eb2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiHorizontalRule, + EuiText, +} from '@elastic/eui'; import React from 'react'; - import styled, { css } from 'styled-components'; + import { UserActionAvatar } from './user_action_avatar'; import { UserActionTitle } from './user_action_title'; +import * as i18n from './translations'; interface UserActionItemProps { createdAt: string; @@ -17,14 +25,20 @@ interface UserActionItemProps { isEditable: boolean; isLoading: boolean; labelEditAction?: string; - labelTitle?: string; + labelTitle?: JSX.Element; + linkId?: string | null; fullName: string; - markdown: React.ReactNode; - onEdit: (id: string) => void; + markdown?: React.ReactNode; + onEdit?: (id: string) => void; userName: string; + updatedAt?: string | null; + outlineComment?: (id: string) => void; + showBottomFooter?: boolean; + showTopFooter?: boolean; + idToOutline?: string | null; } -const UserActionItemContainer = styled(EuiFlexGroup)` +export const UserActionItemContainer = styled(EuiFlexGroup)` ${({ theme }) => css` & { background-image: linear-gradient( @@ -66,42 +80,102 @@ const UserActionItemContainer = styled(EuiFlexGroup)` `} `; +const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + ${({ theme, showoutline }) => + showoutline === 'true' + ? ` + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + ` + : ''} +`; + +const PushedContainer = styled(EuiFlexItem)` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSizeS}; + margin-bottom: ${theme.eui.euiSizeXL}; + hr { + margin: 5px; + height: ${theme.eui.euiBorderWidthThick}; + } + `} +`; + +const PushedInfoContainer = styled.div` + margin-left: 48px; +`; + export const UserActionItem = ({ createdAt, id, + idToOutline, isEditable, isLoading, labelEditAction, labelTitle, + linkId, fullName, markdown, onEdit, + outlineComment, + showBottomFooter, + showTopFooter, userName, + updatedAt, }: UserActionItemProps) => ( - - - {fullName.length > 0 || userName.length > 0 ? ( - - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - - {markdown} - - )} + + + + + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} + + + {isEditable && markdown} + {!isEditable && ( + + } + linkId={linkId} + userName={userName} + updatedAt={updatedAt} + onEdit={onEdit} + outlineComment={outlineComment} + /> + {markdown} + + )} + + + {showTopFooter && ( + + + + {i18n.ALREADY_PUSHED_TO_SERVICE} + + + + {showBottomFooter && ( + + + {i18n.REQUIRED_UPDATE_TO_SERVICE} + + + )} + + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 0ed081e8852f0..6ca81667d9712 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import copy from 'copy-to-clipboard'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo, useCallback } from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; -import { - FormattedRelativePreferenceDate, - FormattedRelativePreferenceLabel, -} from '../../../../components/formatted_date'; -import * as i18n from '../case_view/translations'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { PropertyActions } from '../property_actions'; +import { SiemPageName } from '../../../home/types'; +import * as i18n from './translations'; const MySpinner = styled(EuiLoadingSpinner)` .euiLoadingSpinner { @@ -25,10 +29,13 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelEditAction: string; - labelTitle: string; + labelEditAction?: string; + labelTitle: JSX.Element; + linkId?: string | null; + updatedAt?: string | null; userName: string; - onEdit: (id: string) => void; + onEdit?: (id: string) => void; + outlineComment?: (id: string) => void; } export const UserActionTitle = ({ @@ -37,32 +44,107 @@ export const UserActionTitle = ({ isLoading, labelEditAction, labelTitle, + linkId, userName, + updatedAt, onEdit, + outlineComment, }: UserActionTitleProps) => { + const { detailName: caseId } = useParams(); + const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - return [ + if (labelEditAction != null && onEdit != null) { + return [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ]; + } + return []; + }, [id, labelEditAction, onEdit]); + + const handleAnchorLink = useCallback(() => { + copy( + `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}`, { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - }, [id, onEdit]); + debug: true, + } + ); + }, [caseId, id, urlSearch]); + + const handleMoveToLink = useCallback(() => { + if (outlineComment != null && linkId != null) { + outlineComment(linkId); + } + }, [linkId, outlineComment]); + return ( - + -

- {userName} - {` ${labelTitle} `} - - -

+ + + {userName} + + {labelTitle} + + + + + + {updatedAt != null && ( + + + {'('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + + )} +
- {isLoading && } - {!isLoading && } + + {!isEmpty(linkId) && ( + + + + )} + + + + {propertyActions.length > 0 && ( + + {isLoading && } + {!isLoading && } + + )} +
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx index b546a88744439..b7e7ced308331 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { CaseHeaderPage } from './components/case_header_page'; @@ -13,11 +13,8 @@ import { getCaseUrl } from '../../components/link_to'; import { WhitePageWrapper, SectionWrapper } from './components/wrappers'; import * as i18n from './translations'; import { ConfigureCases } from './components/configure_cases'; - -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; const wrapperPageStyle: Record = { paddingLeft: '0', @@ -25,18 +22,30 @@ const wrapperPageStyle: Record = { paddingBottom: '0', }; -const ConfigureCasesPageComponent: React.FC = () => ( - <> - - - - - - - - - - -); +const ConfigureCasesPageComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); + + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + + + + + ); +}; export const ConfigureCasesPage = React.memo(ConfigureCasesPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx index 2c7525264f71b..bd1f6da0ca28b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { Create } from './components/create'; @@ -12,20 +12,29 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { CaseHeaderPage } from './components/case_header_page'; import * as i18n from './translations'; import { getCaseUrl } from '../../components/link_to'; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +export const CreateCasePage = React.memo(() => { + const search = useGetUrlSearch(navTabs.case); -export const CreateCasePage = React.memo(() => ( - <> - - - - - - -)); + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + ); +}); CreateCasePage.displayName = 'CreateCasePage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx index 1bde9de1535b5..124cefa726a8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -15,6 +15,7 @@ import { ConfigureCasesPage } from './configure_cases'; const casesPagePath = `/:pageName(${SiemPageName.case})`; const caseDetailsPagePath = `${casesPagePath}/:detailName`; +const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`; const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; @@ -29,6 +30,9 @@ const CaseContainerComponent: React.FC = () => ( + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 341a34240fe49..8f9d2087699f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -33,17 +33,15 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { defaultMessage: 'Closed on', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); -export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { +export const REPORTER = i18n.translate('xpack.siem.case.caseView.reporterLabel', { defaultMessage: 'Reporter', }); +export const PARTICIPANTS = i18n.translate('xpack.siem.case.caseView.particpantsLabel', { + defaultMessage: 'Participants', +}); + export const CREATE_BC_TITLE = i18n.translate('xpack.siem.case.caseView.breadcrumb', { defaultMessage: 'Create', }); @@ -90,6 +88,30 @@ export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', defaultMessage: 'Create case', }); +export const CLOSED_CASE = i18n.translate('xpack.siem.case.caseView.closedCase', { + defaultMessage: 'Closed case', +}); + +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseView.closeCase', { + defaultMessage: 'Close case', +}); + +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseView.reopenCase', { + defaultMessage: 'Reopen case', +}); + +export const REOPENED_CASE = i18n.translate('xpack.siem.case.caseView.reopenedCase', { + defaultMessage: 'Reopened case', +}); + +export const CASE_NAME = i18n.translate('xpack.siem.case.caseView.caseName', { + defaultMessage: 'Case name', +}); + +export const TO = i18n.translate('xpack.siem.case.caseView.to', { + defaultMessage: 'to', +}); + export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); @@ -130,7 +152,7 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( ); export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { - defaultMessage: 'Edit third-party connection', + defaultMessage: 'Edit external connection', }); export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index ccb3b71a476ec..df9f0d08e728c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { Breadcrumb } from 'ui/chrome'; + import { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl } from '../../components/link_to'; import { RouteSpyState } from '../../utils/route/types'; import * as i18n from './translations'; -export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { +export const getBreadcrumbs = (params: RouteSpyState, search: string[]): Breadcrumb[] => { + const queryParameters = !isEmpty(search[0]) ? search[0] : null; + let breadcrumb = [ { text: i18n.PAGE_TITLE, - href: getCaseUrl(), + href: getCaseUrl(queryParameters), }, ]; if (params.detailName === 'create') { @@ -21,7 +25,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(), + href: getCreateCaseUrl(queryParameters), }, ]; } else if (params.detailName != null) { @@ -29,7 +33,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName), + href: getCaseDetailsUrl({ id: params.detailName, search: queryParameters }), }, ]; } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index 011a2614c1af9..6d76fde49634d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -6,7 +6,7 @@ import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; -import { AboutStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; +import { AboutStepRule, ActionsStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; import { FieldValueQueryBar } from '../../components/query_bar'; export const mockQueryBar: FieldValueQueryBar = { @@ -40,6 +40,7 @@ export const mockQueryBar: FieldValueQueryBar = { }; export const mockRule = (id: string): Rule => ({ + actions: [], created_at: '2020-01-10T21:11:45.839Z', updated_at: '2020-01-10T21:11:45.839Z', created_by: 'elastic', @@ -70,11 +71,13 @@ export const mockRule = (id: string): Rule => ({ to: 'now', type: 'saved_query', threat: [], + throttle: null, note: '# this is some markdown documentation', version: 1, }); export const mockRuleWithEverything = (id: string): Rule => ({ + actions: [], created_at: '2020-01-10T21:11:45.839Z', updated_at: '2020-01-10T21:11:45.839Z', created_by: 'elastic', @@ -142,6 +145,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({ ], }, ], + throttle: null, note: '# this is some markdown documentation', version: 1, }); @@ -155,10 +159,6 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ references: ['www.test.co'], falsePositives: ['test'], tags: ['tag1', 'tag2'], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, threat: [ { framework: 'mockFramework', @@ -179,6 +179,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ note: '# this is some markdown documentation', }); +export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({ + isNew, + actions: [], + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + enabled, + throttle: null, +}); + export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ isNew, ruleType: 'query', @@ -186,11 +194,14 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ machineLearningJobId: '', index: ['filebeat-'], queryBar: mockQueryBar, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }); -export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({ +export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ isNew, - enabled, interval: '5m', from: '6m', to: 'now', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap index 4d416e70a096c..9a534297e5e29 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap @@ -27,21 +27,6 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against "description": 21, "title": "Risk score", }, - Object { - "description": "Titled timeline", - "title": "Timeline template", - }, - ] - } - /> -
- - , "title": "Reference URLs", }, + ] + } + /> + + + { test('returns expected ListItems array when given valid inputs', () => { const result: ListItems[] = buildListItems(mockAboutStep, schema, mockFilterManager); - expect(result.length).toEqual(10); + expect(result.length).toEqual(9); }); }); @@ -431,10 +431,11 @@ describe('description_step', () => { describe('timeline', () => { test('returns timeline title if one exists', () => { + const mockDefineStep = mockDefineStepRule(); const result: ListItems[] = getDescriptionItem( 'timeline', 'Timeline label', - mockAboutStep, + mockDefineStep, mockFilterManager ); @@ -444,7 +445,7 @@ describe('description_step', () => { test('returns default timeline title if none exists', () => { const mockStep = { - ...mockAboutStep, + ...mockDefineStepRule(), timeline: { id: '12345', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts deleted file mode 100644 index dab1c9490591f..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts +++ /dev/null @@ -1,68 +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 { i18n } from '@kbn/i18n'; - -export const IMPORT_RULE = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle', - { - defaultMessage: 'Import rule', - } -); - -export const SELECT_RULE = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription', - { - defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine UI) to import', - } -); - -export const INITIAL_PROMPT_TEXT = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', - { - defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', - } -); - -export const OVERWRITE_WITH_SAME_NAME = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', - { - defaultMessage: 'Automatically overwrite saved objects with the same rule ID', - } -); - -export const CANCEL_BUTTON = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle', - { - defaultMessage: 'Cancel', - } -); - -export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) => - i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle', - { - values: { totalRules }, - defaultMessage: - 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', - } - ); - -export const IMPORT_FAILED = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle', - { - defaultMessage: 'Failed to import rules', - } -); - -export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => - i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', - { - values: { ruleId, statusCode, message }, - defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', - } - ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..433b38773c14a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NextStep renders correctly against snapshot 1`] = ` + + + + + + Continue + + + + +`; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx similarity index 57% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx rename to x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx index e10194853e7f9..552ede90cd018 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx @@ -6,19 +6,11 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { ImportRuleModalComponent } from './index'; +import { NextStep } from './index'; -jest.mock('../../../../../lib/kibana'); - -describe('ImportRuleModal', () => { +describe('NextStep', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx new file mode 100644 index 0000000000000..11332e7af9266 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import * as RuleI18n from '../../translations'; + +interface NextStepProps { + onClick: () => Promise; + isDisabled: boolean; + dataTestSubj?: string; +} + +export const NextStep = React.memo( + ({ onClick, isDisabled, dataTestSubj = 'nextStep-continue' }) => ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + ) +); + +NextStep.displayName = 'NextStep'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx new file mode 100644 index 0000000000000..a746d381c494c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, { useCallback, useEffect, useState } from 'react'; +import deepMerge from 'deepmerge'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { loadActionTypes } from '../../../../../../../../../plugins/triggers_actions_ui/public/application/lib/action_connector_api'; +import { SelectField } from '../../../../../shared_imports'; +import { + ActionForm, + ActionType, +} from '../../../../../../../../../plugins/triggers_actions_ui/public'; +import { AlertAction } from '../../../../../../../../../plugins/alerting/common'; +import { useKibana } from '../../../../../lib/kibana'; +import { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS } from '../../../../../../common/constants'; + +type ThrottleSelectField = typeof SelectField; + +const DEFAULT_ACTION_GROUP_ID = 'default'; +const DEFAULT_ACTION_MESSAGE = + 'Rule {{context.rule.name}} generated {{state.signals_count}} signals'; + +export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables }) => { + const [supportedActionTypes, setSupportedActionTypes] = useState(); + const { + http, + triggers_actions_ui: { actionTypeRegistry }, + notifications, + } = useKibana().services; + + const setActionIdByIndex = useCallback( + (id: string, index: number) => { + const updatedActions = [...(field.value as Array>)]; + updatedActions[index] = deepMerge(updatedActions[index], { id }); + field.setValue(updatedActions); + }, + [field] + ); + + const setAlertProperty = useCallback( + (updatedActions: AlertAction[]) => field.setValue(updatedActions), + [field] + ); + + const setActionParamsProperty = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (key: string, value: any, index: number) => { + const updatedActions = [...(field.value as AlertAction[])]; + updatedActions[index].params[key] = value; + field.setValue(updatedActions); + }, + [field] + ); + + useEffect(() => { + (async function() { + const actionTypes = await loadActionTypes({ http }); + const supportedTypes = actionTypes.filter(actionType => + NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id) + ); + setSupportedActionTypes(supportedTypes); + })(); + }, []); + + if (!supportedActionTypes) return <>; + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index 417133f230610..52b0038507b59 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -5,7 +5,6 @@ */ import { AboutStepRule } from '../../types'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; export const threatDefault = [ { @@ -24,10 +23,6 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - timeline: { - id: null, - title: DEFAULT_TIMELINE_TITLE, - }, threat: threatDefault, note: '', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx index 0ed479e235151..3c28e697789ac 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.test.tsx @@ -15,6 +15,17 @@ import { stepAboutDefaultValue } from './default_value'; const theme = () => ({ eui: euiDarkVars, darkMode: true }); +/* eslint-disable no-console */ +// Silence until enzyme fixed to use ReactTestUtils.act() +const originalError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalError; +}); +/* eslint-enable no-console */ + describe('StepAboutRuleComponent', () => { test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { const wrapper = shallow( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index bfb123f3f3204..eaf543780d777 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -4,22 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiAccordion, - EuiButton, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { setFieldValue } from '../../helpers'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; -import * as RuleI18n from '../../translations'; import { AddItem } from '../add_item_form'; import { StepRuleDescription } from '../description_step'; import { AddMitreThreat } from '../mitre'; @@ -37,8 +28,8 @@ import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form'; const CommonUseField = getUseField({ component: Field }); @@ -216,15 +207,6 @@ const StepAboutRuleComponent: FC = ({ buttonContent={AdvancedSettingsAccordionButton} > - = ({ + {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 7c1ab09b7309c..8cb38b9dc7393 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 @@ -91,21 +91,6 @@ export const schema: FormSchema = { } ), }, - timeline: { - label: i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', - { - defaultMessage: 'Timeline template', - } - ), - helpText: i18n.translate( - 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateHelpText', - { - defaultMessage: - 'Select an existing timeline to use as a template when investigating generated signals.', - } - ), - }, references: { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldReferenceUrlsLabel', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx index c61566cb841e8..5d9803214fa0a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx @@ -15,7 +15,7 @@ import { EuiFlexGroup, EuiResizeObserver, } from '@elastic/eui'; -import React, { memo, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; @@ -71,9 +71,12 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ const [selectedToggleOption, setToggleOption] = useState('details'); const [aboutPanelHeight, setAboutPanelHeight] = useState(0); - const onResize = (e: { height: number; width: number }) => { - setAboutPanelHeight(e.height); - }; + const onResize = useCallback( + (e: { height: number; width: number }) => { + setAboutPanelHeight(e.height); + }, + [setAboutPanelHeight] + ); return ( @@ -85,7 +88,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ )} {stepData != null && stepDataDetails != null && ( - + {!isEmpty(stepDataDetails.note) && stepDataDetails.note.trim() !== '' && ( = ({ )} - + {selectedToggleOption === 'details' ? ( {resizeRef => ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index cf8cc4b87b388..68ca1840871e3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -4,15 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonEmpty, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiButton, -} from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; +import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -20,17 +12,19 @@ import deepEqual from 'fast-deep-equal'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { setFieldValue, isMlRule } from '../../helpers'; -import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; import { SelectRuleType } from '../select_rule_type'; import { AnomalyThresholdSlider } from '../anomaly_threshold_slider'; import { MlJobSelect } from '../ml_job_select'; +import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { Field, Form, @@ -61,6 +55,10 @@ const stepDefineDefaultValue: DefineStepRule = { filters: [], saved_id: undefined, }, + timeline: { + id: null, + title: DEFAULT_TIMELINE_TITLE, + }, }; const MyLabelButton = styled(EuiButtonEmpty)` @@ -77,23 +75,6 @@ MyLabelButton.defaultProps = { flush: 'right', }; -const getStepDefaultValue = ( - indicesConfig: string[], - defaultValues: DefineStepRule | null -): DefineStepRule => { - if (defaultValues != null) { - return { - ...defaultValues, - isNew: false, - }; - } else { - return { - ...stepDefineDefaultValue, - index: indicesConfig != null ? indicesConfig : [], - }; - } -}; - const StepDefineRuleComponent: FC = ({ addPadding = false, defaultValues, @@ -106,18 +87,16 @@ const StepDefineRuleComponent: FC = ({ }) => { const mlCapabilities = useContext(MlCapabilitiesContext); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indexModified, setIndexModified] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); + const [myStepData, setMyStepData] = useState({ + ...stepDefineDefaultValue, + index: indicesConfig ?? [], + }); const [ { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); + ] = useFetchIndexPatterns(myStepData.index); const { form } = useForm({ defaultValue: myStepData, @@ -138,15 +117,13 @@ const StepDefineRuleComponent: FC = ({ }, [form]); useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!deepEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(deepEqual(myDefaultValues.index, indicesConfig)); - setFieldValue(form, schema, myDefaultValues); - } + const { isNew, ...values } = myStepData; + if (defaultValues != null && !deepEqual(values, defaultValues)) { + const newValues = { ...values, ...defaultValues, isNew: false }; + setMyStepData(newValues); + setFieldValue(form, schema, newValues); } - }, [defaultValues, indicesConfig]); + }, [defaultValues, setMyStepData, setFieldValue]); useEffect(() => { if (setForm != null) { @@ -195,7 +172,7 @@ const StepDefineRuleComponent: FC = ({ path="index" config={{ ...schema.index, - labelAppend: !localUseIndicesConfig ? ( + labelAppend: indexModified ? ( {i18n.RESET_DEFAULT_INDEX} @@ -253,17 +230,22 @@ const StepDefineRuleComponent: FC = ({ /> + {({ index, ruleType }) => { if (index != null) { - if (deepEqual(index, indicesConfig) && !localUseIndicesConfig) { - setLocalUseIndicesConfig(true); - } - if (!deepEqual(index, indicesConfig) && localUseIndicesConfig) { - setLocalUseIndicesConfig(false); - } - if (index != null && !isEmpty(index) && !deepEqual(index, mylocalIndicesConfig)) { - setMyLocalIndicesConfig(index); + if (deepEqual(index, indicesConfig) && indexModified) { + setIndexModified(false); + } else if (!deepEqual(index, indicesConfig) && !indexModified) { + setIndexModified(true); } } @@ -280,22 +262,9 @@ const StepDefineRuleComponent: FC = ({ + {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index bcfcd4f4ee09d..271c8fabed3a5 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 @@ -158,4 +158,19 @@ export const schema: FormSchema = { }, ], }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateHelpText', + { + defaultMessage: + 'Select an existing timeline to use as a template when investigating generated signals.', + } + ), + }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx new file mode 100644 index 0000000000000..9c16a61822662 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer } from '@elastic/eui'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; + +import { setFieldValue } from '../../helpers'; +import { RuleStep, RuleStepProps, ActionsStepRule } from '../../types'; +import { StepRuleDescription } from '../description_step'; +import { Form, UseField, useForm } from '../../../../../shared_imports'; +import { StepContentWrapper } from '../step_content_wrapper'; +import { ThrottleSelectField, THROTTLE_OPTIONS } from '../throttle_select_field'; +import { RuleActionsField } from '../rule_actions_field'; +import { useKibana } from '../../../../../lib/kibana'; +import { schema } from './schema'; +import * as I18n from './translations'; + +interface StepRuleActionsProps extends RuleStepProps { + defaultValues?: ActionsStepRule | null; + actionMessageParams: string[]; +} + +const stepActionsDefaultValue = { + enabled: true, + isNew: true, + actions: [], + kibanaSiemAppUrl: '', + throttle: THROTTLE_OPTIONS[0].value, +}; + +const GhostFormField = () => <>; + +const StepRuleActionsComponent: FC = ({ + addPadding = false, + defaultValues, + isReadOnlyView, + isLoading, + isUpdateView = false, + setStepData, + setForm, + actionMessageParams, +}) => { + const [myStepData, setMyStepData] = useState(stepActionsDefaultValue); + const { + services: { application }, + } = useKibana(); + + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); + + const kibanaAbsoluteUrl = useMemo(() => application.getUrlForApp('siem', { absolute: true }), [ + application, + ]); + + const onSubmit = useCallback( + async (enabled: boolean) => { + if (setStepData) { + setStepData(RuleStep.ruleActions, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); + setMyStepData({ ...data, isNew: false } as ActionsStepRule); + } + } + }, + [form] + ); + + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + setFieldValue(form, schema, myDefaultValues); + } + }, [defaultValues]); + + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.ruleActions, form); + } + }, [form]); + + const updateThrottle = useCallback(throttle => setMyStepData({ ...myStepData, throttle }), [ + myStepData, + setMyStepData, + ]); + + const throttleFieldComponentProps = useMemo( + () => ({ + idAria: 'detectionEngineStepRuleActionsThrottle', + isDisabled: isLoading, + dataTestSubj: 'detectionEngineStepRuleActionsThrottle', + hasNoInitialSelection: false, + handleChange: updateThrottle, + euiFieldProps: { + options: THROTTLE_OPTIONS, + }, + }), + [isLoading, updateThrottle] + ); + + return isReadOnlyView && myStepData != null ? ( + + + + ) : ( + <> + +
+ + {myStepData.throttle !== stepActionsDefaultValue.throttle && ( + <> + + + + + )} + +
+ + {!isUpdateView && ( + <> + + + + + {I18n.COMPLETE_WITHOUT_ACTIVATING} + + + + + {I18n.COMPLETE_WITH_ACTIVATING} + + + + + )} + + ); +}; + +export const StepRuleActions = memo(StepRuleActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx new file mode 100644 index 0000000000000..511427978db3a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { FormSchema } from '../../../../../shared_imports'; + +export const schema: FormSchema = { + actions: {}, + kibanaSiemAppUrl: {}, + throttle: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleLabel', + { + defaultMessage: 'Actions frequency', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleHelpText', + { + defaultMessage: + 'Select when automated actions should be performed if a rule evaluates as true.', + } + ), + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx new file mode 100644 index 0000000000000..67bcc1af8150b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.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 { i18n } from '@kbn/i18n'; + +export const COMPLETE_WITHOUT_ACTIVATING = i18n.translate( + 'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle', + { + defaultMessage: 'Create rule without activating it', + } +); + +export const COMPLETE_WITH_ACTIVATING = i18n.translate( + 'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle', + { + defaultMessage: 'Create & activate rule', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index e365443a79fb8..de9abcefdea2e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; @@ -15,8 +14,8 @@ import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { schema } from './schema'; -import * as I18n from './translations'; interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; @@ -27,7 +26,6 @@ const RestrictedWidthContainer = styled.div` `; const stepScheduleDefaultValue = { - enabled: true, interval: '5m', isNew: true, from: '1m', @@ -51,19 +49,16 @@ const StepScheduleRuleComponent: FC = ({ schema, }); - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.scheduleRule, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); + setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } - }, - [form] - ); + } + }, [form]); useEffect(() => { const { isNew, ...initDefaultValue } = myStepData; @@ -118,37 +113,7 @@ const StepScheduleRuleComponent: FC = ({ {!isUpdateView && ( - <> - - - - - {I18n.COMPLETE_WITHOUT_ACTIVATING} - - - - - {I18n.COMPLETE_WITH_ACTIVATING} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx new file mode 100644 index 0000000000000..0cf15c41a0f91 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.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, { useCallback } from 'react'; + +import { + NOTIFICATION_THROTTLE_RULE, + NOTIFICATION_THROTTLE_NO_ACTIONS, +} from '../../../../../../common/constants'; +import { SelectField } from '../../../../../shared_imports'; + +export const THROTTLE_OPTIONS = [ + { value: NOTIFICATION_THROTTLE_NO_ACTIONS, text: 'Perform no actions' }, + { value: NOTIFICATION_THROTTLE_RULE, text: 'On each rule execution' }, + { value: '1h', text: 'Hourly' }, + { value: '1d', text: 'Daily' }, + { value: '7d', text: 'Weekly' }, +]; + +type ThrottleSelectField = typeof SelectField; + +export const ThrottleSelectField: ThrottleSelectField = props => { + const onChange = useCallback( + e => { + const throttle = e.target.value; + props.field.setValue(throttle); + props.handleChange(throttle); + }, + [props.field.setValue, props.handleChange] + ); + const newEuiFieldProps = { ...props.euiFieldProps, onChange }; + return ; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index ea6b02924cb3e..212147ec6d4d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -9,7 +9,9 @@ import { DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, + ActionsStepRuleJson, AboutStepRule, + ActionsStepRule, ScheduleStepRule, DefineStepRule, } from '../types'; @@ -18,13 +20,16 @@ import { formatDefineStepData, formatScheduleStepData, formatAboutStepData, + formatActionsStepData, formatRule, + filterRuleFieldsForType, } from './helpers'; import { mockDefineStepRule, mockQueryBar, mockScheduleStepRule, mockAboutStepRule, + mockActionsStepRule, } from '../all/__mocks__/mock'; describe('helpers', () => { @@ -88,6 +93,8 @@ describe('helpers', () => { saved_id: 'test123', index: ['filebeat-'], type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -109,6 +116,119 @@ describe('helpers', () => { index: ['filebeat-'], saved_id: '', type: 'query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { + const mockStepData = { + ...mockData, + }; + delete mockStepData.timeline.id; + + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + }, + }; + delete mockStepData.timeline.title; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + title: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: '', + }; + + expect(result).toEqual(expected); + }); + + test('returns ML fields if type is machine_learning', () => { + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_jobert_id', + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_jobert_id', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -125,7 +245,6 @@ describe('helpers', () => { test('returns formatted object as ScheduleStepRuleJson', () => { const result: ScheduleStepRuleJson = formatScheduleStepData(mockData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -144,7 +263,6 @@ describe('helpers', () => { delete mockStepData.to; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -163,7 +281,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -182,7 +299,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-300s', to: 'now', interval: '5m', @@ -201,7 +317,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-360s', to: 'now', interval: 'random', @@ -249,8 +364,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -289,8 +402,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -327,31 +438,17 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); }); - test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { + test('returns formatted object with threats filtered out where tactic.name is "none"', () => { const mockStepData = { ...mockData, - }; - delete mockStepData.timeline.id; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], threat: [ { - framework: 'MITRE ATT&CK', + framework: 'mockFramework', tactic: { id: '1234', name: 'tactic1', @@ -365,36 +462,11 @@ describe('helpers', () => { }, ], }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ { - framework: 'MITRE ATT&CK', + framework: 'mockFramework', tactic: { id: '1234', - name: 'tactic1', + name: 'none', reference: 'reference1', }, technique: [ @@ -406,22 +478,7 @@ describe('helpers', () => { ], }, ], - timeline_id: '', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - }, }; - delete mockStepData.timeline.title; const result: AboutStepRuleJson = formatAboutStepData(mockStepData); const expected = { description: '24/7', @@ -435,112 +492,153 @@ describe('helpers', () => { threat: [ { framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], + tactic: { id: '1234', name: 'tactic1', reference: 'reference1' }, + technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], }, ], }; expect(result).toEqual(expected); }); + }); - test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { + describe('formatActionsStepData', () => { + let mockData: ActionsStepRule; + + beforeEach(() => { + mockData = mockActionsStepRule(); + }); + + test('returns formatted object as ActionsStepRuleJson', () => { + const result: ActionsStepRuleJson = formatActionsStepData(mockData); + const expected = { + actions: [], + enabled: false, + meta: { + throttle: 'no_actions', + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for no_actions', () => { const mockStepData = { ...mockData, - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: '', + throttle: 'no_actions', + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, }, + throttle: null, }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for rule', () => { + const mockStepData = { + ...mockData, + throttle: 'rule', + actions: [ + { + group: 'default', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ + actions: [ { - framework: 'MITRE ATT&CK', - tactic: { id: '1234', name: 'tactic1', reference: 'reference1' }, - technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], + group: mockStepData.actions[0].group, + id: mockStepData.actions[0].id, + action_type_id: mockStepData.actions[0].actionTypeId, + params: mockStepData.actions[0].params, }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: '', + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, }; expect(result).toEqual(expected); }); - test('returns formatted object with threats filtered out where tactic.name is "none"', () => { + test('returns proper throttle value for interval', () => { const mockStepData = { ...mockData, - threat: [ + throttle: '1d', + actions: [ { - framework: 'mockFramework', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], + group: 'default', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, }, + ], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ { - framework: 'mockFramework', - tactic: { - id: '1234', - name: 'none', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], + group: mockStepData.actions[0].group, + id: mockStepData.actions[0].id, + action_type_id: mockStepData.actions[0].actionTypeId, + params: mockStepData.actions[0].params, }, ], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: mockStepData.throttle, }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); + + expect(result).toEqual(expected); + }); + + test('returns actions with action_type_id', () => { + const mockAction = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'ML Rule generated {{state.signals_count}} signals' }, + actionTypeId: '.slack', + }; + + const mockStepData = { + ...mockData, + actions: [mockAction], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ + actions: [ { - framework: 'MITRE ATT&CK', - tactic: { id: '1234', name: 'tactic1', reference: 'reference1' }, - technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], + group: mockAction.group, + id: mockAction.id, + params: mockAction.params, + action_type_id: mockAction.actionTypeId, }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', + enabled: false, + meta: { + throttle: null, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, }; expect(result).toEqual(expected); @@ -551,15 +649,17 @@ describe('helpers', () => { let mockAbout: AboutStepRule; let mockDefine: DefineStepRule; let mockSchedule: ScheduleStepRule; + let mockActions: ActionsStepRule; beforeEach(() => { mockAbout = mockAboutStepRule(); mockDefine = mockDefineStepRule(); mockSchedule = mockScheduleStepRule(); + mockActions = mockActionsStepRule(); }); test('returns NewRule with type of saved_query when saved_id exists', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); + const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); @@ -572,15 +672,64 @@ describe('helpers', () => { saved_id: '', }, }; - const result: NewRule = formatRule(mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule); + const result: NewRule = formatRule( + mockDefineStepRuleWithoutSavedId, + mockAbout, + mockSchedule, + mockActions + ); expect(result.type).toEqual('query'); }); test('returns NewRule without id if ruleId does not exist', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); + const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.id).toBeUndefined(); }); }); + + describe('filterRuleFieldsForType', () => { + let fields: DefineStepRule; + + beforeEach(() => { + fields = mockDefineStepRule(); + }); + + it('removes query fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).not.toHaveProperty('index'); + expect(result).not.toHaveProperty('queryBar'); + }); + + it('leaves ML fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('anomalyThreshold'); + expect(result).toHaveProperty('machineLearningJobId'); + }); + + it('leaves arbitrary fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + + it('removes ML fields if the type is not machine learning', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).not.toHaveProperty('anomalyThreshold'); + expect(result).not.toHaveProperty('machineLearningJobId'); + }); + + it('leaves query fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('index'); + expect(result).toHaveProperty('queryBar'); + }); + + it('leaves arbitrary fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + }); }); 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 1f3379bf681bb..7abe5a576c0e5 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 @@ -6,16 +6,24 @@ import { has, isEmpty } from 'lodash/fp'; import moment from 'moment'; +import deepmerge from 'deepmerge'; +import { + NOTIFICATION_THROTTLE_RULE, + NOTIFICATION_THROTTLE_NO_ACTIONS, +} from '../../../../../common/constants'; import { NewRule, RuleType } from '../../../../containers/detection_engine/rules'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; import { AboutStepRule, DefineStepRule, ScheduleStepRule, + ActionsStepRule, DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, + ActionsStepRuleJson, } from '../types'; import { isMlRule } from '../helpers'; @@ -64,27 +72,35 @@ export const filterRuleFieldsForType = (fields: T, type: R export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const ruleFields = filterRuleFieldsForType(defineStepData, defineStepData.ruleType); + const { ruleType, timeline } = ruleFields; + const baseFields = { + type: ruleType, + ...(timeline.id != null && + timeline.title != null && { + timeline_id: timeline.id, + timeline_title: timeline.title, + }), + }; - if (isMlFields(ruleFields)) { - const { anomalyThreshold, machineLearningJobId, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - anomaly_threshold: anomalyThreshold, - machine_learning_job_id: machineLearningJobId, - }; - } else { - const { queryBar, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - filters: queryBar?.filters, - language: queryBar?.query?.language, - query: queryBar?.query?.query as string, - saved_id: queryBar?.saved_id, - ...(ruleType === 'query' && queryBar?.saved_id ? { type: 'saved_query' as RuleType } : {}), - }; - } + const typeFields = isMlFields(ruleFields) + ? { + anomaly_threshold: ruleFields.anomalyThreshold, + machine_learning_job_id: ruleFields.machineLearningJobId, + } + : { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + ...(ruleType === 'query' && + ruleFields.queryBar?.saved_id && { type: 'saved_query' as RuleType }), + }; + + return { + ...baseFields, + ...typeFields, + }; }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { @@ -108,26 +124,11 @@ export const formatScheduleStepData = (scheduleData: ScheduleStepRule): Schedule }; export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { - falsePositives, - references, - riskScore, - threat, - timeline, - isNew, - note, - ...rest - } = aboutStepData; + const { falsePositives, references, riskScore, threat, isNew, note, ...rest } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, - ...(timeline.id != null && timeline.title != null - ? { - timeline_id: timeline.id, - timeline_title: timeline.title, - } - : {}), threat: threat .filter(singleThreat => singleThreat.tactic.name !== 'none') .map(singleThreat => ({ @@ -143,12 +144,39 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule }; }; +export const getAlertThrottle = (throttle: string | null) => + throttle && ![NOTIFICATION_THROTTLE_NO_ACTIONS, NOTIFICATION_THROTTLE_RULE].includes(throttle) + ? throttle + : null; + +export const formatActionsStepData = (actionsStepData: ActionsStepRule): ActionsStepRuleJson => { + const { + actions = [], + enabled, + kibanaSiemAppUrl, + throttle = NOTIFICATION_THROTTLE_NO_ACTIONS, + } = actionsStepData; + + return { + actions: actions.map(transformAlertToRuleAction), + enabled, + throttle: actions.length ? getAlertThrottle(throttle) : null, + meta: { + throttle: actions.length ? throttle : NOTIFICATION_THROTTLE_NO_ACTIONS, + kibanaSiemAppUrl, + }, + }; +}; + export const formatRule = ( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, - scheduleData: ScheduleStepRule -): NewRule => ({ - ...formatDefineStepData(defineStepData), - ...formatAboutStepData(aboutStepData), - ...formatScheduleStepData(scheduleData), -}); + scheduleData: ScheduleStepRule, + actionsData: ActionsStepRule +): NewRule => + deepmerge.all([ + formatDefineStepData(defineStepData), + formatAboutStepData(aboutStepData), + formatScheduleStepData(scheduleData), + formatActionsStepData(actionsData), + ]) as NewRule; 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 67aaabfe70fda..0335216672915 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 @@ -5,7 +5,7 @@ */ import { EuiButtonEmpty, EuiAccordion, EuiHorizontalRule, EuiPanel, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState, useMemo } from 'react'; import { Redirect } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; @@ -21,14 +21,27 @@ import { FormData, FormHook } from '../../../../shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; +import { StepRuleActions } from '../components/step_rule_actions'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; -import { redirectToDetections } from '../helpers'; -import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types'; +import { redirectToDetections, getActionMessageParams } from '../helpers'; +import { + AboutStepRule, + DefineStepRule, + RuleStep, + RuleStepData, + ScheduleStepRule, + ActionsStepRule, +} from '../types'; import { formatRule } from './helpers'; import * as i18n from './translations'; -const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule]; +const stepsRuleOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, +]; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -79,22 +92,31 @@ const CreateRulePageComponent: React.FC = () => { const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); + const ruleActionsRef = useRef(null); const stepsForm = useRef | null>>({ [RuleStep.defineRule]: null, [RuleStep.aboutRule]: null, [RuleStep.scheduleRule]: null, + [RuleStep.ruleActions]: null, }); const stepsData = useRef>({ [RuleStep.defineRule]: { isValid: false, data: {} }, [RuleStep.aboutRule]: { isValid: false, data: {} }, [RuleStep.scheduleRule]: { isValid: false, data: {} }, + [RuleStep.ruleActions]: { isValid: false, data: {} }, }); const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ [RuleStep.defineRule]: false, [RuleStep.aboutRule]: false, [RuleStep.scheduleRule]: false, + [RuleStep.ruleActions]: false, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const actionMessageParams = useMemo( + () => + getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType), + [stepsData.current['define-rule'].data] + ); const userHasNoPermissions = canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; @@ -103,7 +125,7 @@ const CreateRulePageComponent: React.FC = () => { stepsData.current[step] = { ...stepsData.current[step], data, isValid }; if (isValid) { const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item); - if ([0, 1].includes(stepRuleIdx)) { + if ([0, 1, 2].includes(stepRuleIdx)) { if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); setIsStepRuleInEditView({ @@ -120,15 +142,17 @@ const CreateRulePageComponent: React.FC = () => { setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); } } else if ( - stepRuleIdx === 2 && + stepRuleIdx === 3 && stepsData.current[RuleStep.defineRule].isValid && - stepsData.current[RuleStep.aboutRule].isValid + stepsData.current[RuleStep.aboutRule].isValid && + stepsData.current[RuleStep.scheduleRule].isValid ) { setRule( formatRule( stepsData.current[RuleStep.defineRule].data as DefineStepRule, stepsData.current[RuleStep.aboutRule].data as AboutStepRule, - stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule + stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, + stepsData.current[RuleStep.ruleActions].data as ActionsStepRule ) ); } @@ -177,6 +201,14 @@ const CreateRulePageComponent: React.FC = () => { /> ); + const ruleActionsButton = ( + + ); + const openCloseAccordion = (accordionId: RuleStep | null) => { if (accordionId != null) { if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) { @@ -185,6 +217,8 @@ const CreateRulePageComponent: React.FC = () => { aboutRuleRef.current.onToggle(); } else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) { scheduleRuleRef.current.onToggle(); + } else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) { + ruleActionsRef.current.onToggle(); } } }; @@ -253,7 +287,7 @@ const CreateRulePageComponent: React.FC = () => { isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { - + { - + { /> + + + + {i18n.EDIT_RULE} + + ) + } + > + + + + 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 8618bf9504861..f89e3206cc67d 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 @@ -31,10 +31,17 @@ import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; +import { StepRuleActions } from '../components/step_rule_actions'; import { formatRule } from '../create/helpers'; -import { getStepsData, redirectToDetections } from '../helpers'; +import { getStepsData, redirectToDetections, getActionMessageParams } from '../helpers'; import * as ruleI18n from '../translations'; -import { RuleStep, DefineStepRule, AboutStepRule, ScheduleStepRule } from '../types'; +import { + RuleStep, + DefineStepRule, + AboutStepRule, + ScheduleStepRule, + ActionsStepRule, +} from '../types'; import * as i18n from './translations'; interface StepRuleForm { @@ -50,6 +57,10 @@ interface ScheduleStepRuleForm extends StepRuleForm { data: ScheduleStepRule | null; } +interface ActionsStepRuleForm extends StepRuleForm { + data: ActionsStepRule | null; +} + const EditRulePageComponent: FC = () => { const [, dispatchToaster] = useStateToaster(); const { @@ -79,14 +90,20 @@ const EditRulePageComponent: FC = () => { data: null, isValid: false, }); + const [myActionsRuleForm, setMyActionsRuleForm] = useState({ + data: null, + isValid: false, + }); const [selectedTab, setSelectedTab] = useState(); const stepsForm = useRef | null>>({ [RuleStep.defineRule]: null, [RuleStep.aboutRule]: null, [RuleStep.scheduleRule]: null, + [RuleStep.ruleActions]: null, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); const [tabHasError, setTabHasError] = useState([]); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); const setStepsForm = useCallback( (step: RuleStep, form: FormHook) => { stepsForm.current[step] = form; @@ -162,6 +179,28 @@ const EditRulePageComponent: FC = () => { ), }, + { + id: RuleStep.ruleActions, + name: ruleI18n.ACTIONS, + content: ( + <> + + + {myActionsRuleForm.data != null && ( + + )} + + + + ), + }, ], [ loading, @@ -170,8 +209,10 @@ const EditRulePageComponent: FC = () => { myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, + myActionsRuleForm, setStepsForm, stepsForm, + actionMessageParams, ] ); @@ -179,14 +220,18 @@ const EditRulePageComponent: FC = () => { const activeFormId = selectedTab?.id as RuleStep; const activeForm = await stepsForm.current[activeFormId]?.submit(); - const invalidForms = [RuleStep.aboutRule, RuleStep.defineRule, RuleStep.scheduleRule].reduce< - RuleStep[] - >((acc, step) => { + const invalidForms = [ + RuleStep.aboutRule, + RuleStep.defineRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, + ].reduce((acc, step) => { if ( (step === activeFormId && activeForm != null && !activeForm?.isValid) || (step === RuleStep.aboutRule && !myAboutRuleForm.isValid) || (step === RuleStep.defineRule && !myDefineRuleForm.isValid) || - (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) + (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) || + (step === RuleStep.ruleActions && !myActionsRuleForm.isValid) ) { return [...acc, step]; } @@ -205,21 +250,35 @@ const EditRulePageComponent: FC = () => { : myAboutRuleForm.data) as AboutStepRule, (activeFormId === RuleStep.scheduleRule ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule + : myScheduleRuleForm.data) as ScheduleStepRule, + (activeFormId === RuleStep.ruleActions + ? activeForm.data + : myActionsRuleForm.data) as ActionsStepRule ), ...(ruleId ? { id: ruleId } : {}), }); } else { setTabHasError(invalidForms); } - }, [stepsForm, myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, selectedTab, ruleId]); + }, [ + stepsForm, + myAboutRuleForm, + myDefineRuleForm, + myScheduleRuleForm, + myActionsRuleForm, + selectedTab, + ruleId, + ]); useEffect(() => { if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule }); + const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ + rule, + }); setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); setMyDefineRuleForm({ data: defineRuleData, isValid: true }); setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); + setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); } }, [rule]); @@ -228,6 +287,7 @@ const EditRulePageComponent: FC = () => { if (selectedTab != null) { const ruleStep = selectedTab.id as RuleStep; const respForm = await stepsForm.current[ruleStep]?.submit(); + if (respForm != null) { if (ruleStep === RuleStep.aboutRule) { setMyAboutRuleForm({ @@ -244,6 +304,11 @@ const EditRulePageComponent: FC = () => { data: respForm.data as ScheduleStepRule, isValid: respForm.isValid, }); + } else if (ruleStep === RuleStep.ruleActions) { + setMyActionsRuleForm({ + data: respForm.data as ActionsStepRule, + isValid: respForm.isValid, + }); } } } @@ -255,10 +320,13 @@ const EditRulePageComponent: FC = () => { useEffect(() => { if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule }); + const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ + rule, + }); setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); setMyDefineRuleForm({ data: defineRuleData, isValid: true }); setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); + setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); } }, [rule]); @@ -303,6 +371,8 @@ const EditRulePageComponent: FC = () => { return ruleI18n.DEFINITION; } else if (t === RuleStep.scheduleRule) { return ruleI18n.SCHEDULE; + } else if (t === RuleStep.ruleActions) { + return ruleI18n.RULE_ACTIONS; } return t; }) diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index ee43ae5f1d6e2..fbdfcf4fc75d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -10,6 +10,7 @@ import { getScheduleStepsData, getStepsData, getAboutStepsData, + getActionsStepsData, getHumanizedDuration, getModifiedAboutDetailsData, determineDetailsValue, @@ -17,16 +18,23 @@ import { import { mockRuleWithEverything, mockRule } from './all/__mocks__/mock'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; -import { AboutStepRule, AboutStepRuleDetails, DefineStepRule, ScheduleStepRule } from './types'; +import { + AboutStepRule, + AboutStepRuleDetails, + DefineStepRule, + ScheduleStepRule, + ActionsStepRule, +} from './types'; describe('rule helpers', () => { describe('getStepsData', () => { - test('returns object with about, define, and schedule step properties formatted', () => { + test('returns object with about, define, schedule and actions step properties formatted', () => { const { defineRuleData, modifiedAboutRuleDetailsData, aboutRuleData, scheduleRuleData, + ruleActionsData, }: GetStepsData = getStepsData({ rule: mockRuleWithEverything('test-id'), }); @@ -65,6 +73,10 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }; const aboutRuleStepData = { description: '24/7', @@ -93,12 +105,9 @@ describe('rule helpers', () => { ], }, ], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, }; - const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false }; + const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false }; + const ruleActionsStepData = { enabled: true, throttle: undefined, isNew: false, actions: [] }; const aboutRuleDataDetailsData = { note: '# this is some markdown documentation', description: '24/7', @@ -107,21 +116,12 @@ describe('rule helpers', () => { expect(defineRuleData).toEqual(defineRuleStepData); expect(aboutRuleData).toEqual(aboutRuleStepData); expect(scheduleRuleData).toEqual(scheduleRuleStepData); + expect(ruleActionsData).toEqual(ruleActionsStepData); expect(modifiedAboutRuleDetailsData).toEqual(aboutRuleDataDetailsData); }); }); describe('getAboutStepsData', () => { - test('returns timeline id and title of null if they do not exist on rule', () => { - const mockedRule = mockRuleWithEverything('test-id'); - delete mockedRule.timeline_id; - delete mockedRule.timeline_title; - const result: AboutStepRule = getAboutStepsData(mockedRule, false); - - expect(result.timeline.id).toBeNull(); - expect(result.timeline.title).toBeNull(); - }); - test('returns name, description, and note as empty string if detailsView is true', () => { const result: AboutStepRule = getAboutStepsData(mockRuleWithEverything('test-id'), true); @@ -195,6 +195,10 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); @@ -220,10 +224,24 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); }); + + test('returns timeline id and title of null if they do not exist on rule', () => { + const mockedRule = mockRuleWithEverything('test-id'); + delete mockedRule.timeline_id; + delete mockedRule.timeline_title; + const result: DefineStepRule = getDefineStepsData(mockedRule); + + expect(result.timeline.id).toBeNull(); + expect(result.timeline.title).toBeNull(); + }); }); describe('getHumanizedDuration', () => { @@ -266,7 +284,6 @@ describe('rule helpers', () => { const result: ScheduleStepRule = getScheduleStepsData(mockedRule); const expected = { isNew: false, - enabled: mockedRule.enabled, interval: mockedRule.interval, from: '0s', }; @@ -275,6 +292,24 @@ describe('rule helpers', () => { }); }); + describe('getActionsStepsData', () => { + test('returns expected ActionsStepRule rule object', () => { + const mockedRule = { + ...mockRule('test-id'), + actions: [], + }; + const result: ActionsStepRule = getActionsStepsData(mockedRule); + const expected = { + actions: [], + enabled: mockedRule.enabled, + isNew: false, + throttle: undefined, + }; + + expect(result).toEqual(expected); + }); + }); + describe('getModifiedAboutDetailsData', () => { test('returns object with "note" and "description" being those of passed in rule', () => { const result: AboutStepRuleDetails = getModifiedAboutDetailsData( 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 e59ca5e7e14e5..50b76552ddc8f 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 @@ -7,8 +7,11 @@ import dateMath from '@elastic/datemath'; import { get } from 'lodash/fp'; import moment from 'moment'; +import memoizeOne from 'memoize-one'; import { useLocation } from 'react-router-dom'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { Rule, RuleType } from '../../../containers/detection_engine/rules'; import { FormData, FormHook, FormSchema } from '../../../shared_imports'; @@ -18,6 +21,7 @@ import { DefineStepRule, IMitreEnterpriseAttack, ScheduleStepRule, + ActionsStepRule, } from './types'; export interface GetStepsData { @@ -25,6 +29,7 @@ export interface GetStepsData { modifiedAboutRuleDetailsData: AboutStepRuleDetails; defineRuleData: DefineStepRule; scheduleRuleData: ScheduleStepRule; + ruleActionsData: ActionsStepRule; } export const getStepsData = ({ @@ -38,32 +43,54 @@ export const getStepsData = ({ const aboutRuleData: AboutStepRule = getAboutStepsData(rule, detailsView); const modifiedAboutRuleDetailsData: AboutStepRuleDetails = getModifiedAboutDetailsData(rule); const scheduleRuleData: ScheduleStepRule = getScheduleStepsData(rule); + const ruleActionsData: ActionsStepRule = getActionsStepsData(rule); - return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData }; + return { + aboutRuleData, + modifiedAboutRuleDetailsData, + defineRuleData, + scheduleRuleData, + ruleActionsData, + }; }; -export const getDefineStepsData = (rule: Rule): DefineStepRule => { +export const getActionsStepsData = ( + rule: Omit & { actions: RuleAlertAction[] } +): ActionsStepRule => { + const { enabled, actions = [], meta } = rule; + return { + actions: actions?.map(transformRuleToAlertAction), isNew: false, - ruleType: rule.type, - anomalyThreshold: rule.anomaly_threshold ?? 50, - machineLearningJobId: rule.machine_learning_job_id ?? '', - index: rule.index ?? [], - queryBar: { - query: { query: rule.query ?? '', language: rule.language ?? '' }, - filters: (rule.filters ?? []) as Filter[], - saved_id: rule.saved_id, - }, + throttle: meta?.throttle, + kibanaSiemAppUrl: meta?.kibanaSiemAppUrl, + enabled, }; }; +export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ + isNew: false, + ruleType: rule.type, + anomalyThreshold: rule.anomaly_threshold ?? 50, + machineLearningJobId: rule.machine_learning_job_id ?? '', + index: rule.index ?? [], + queryBar: { + query: { query: rule.query ?? '', language: rule.language ?? '' }, + filters: (rule.filters ?? []) as Filter[], + saved_id: rule.saved_id, + }, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, +}); + export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { - const { enabled, interval, from } = rule; + const { interval, from } = rule; const fromHumanizedValue = getHumanizedDuration(from, interval); return { isNew: false, - enabled, interval, from: fromHumanizedValue, }; @@ -94,8 +121,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu risk_score: riskScore, tags, threat, - timeline_id: timelineId, - timeline_title: timelineTitle, } = rule; return { @@ -109,10 +134,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu riskScore, falsePositives, threat: threat as IMitreEnterpriseAttack[], - timeline: { - id: timelineId ?? null, - title: timelineTitle ?? null, - }, }; }; @@ -204,3 +225,46 @@ export const redirectToDetections = ( isAuthenticated != null && hasEncryptionKey != null && (!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey); + +export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { + const commonRuleParamsKeys = [ + 'id', + 'name', + 'description', + 'false_positives', + 'rule_id', + 'max_signals', + 'risk_score', + 'output_index', + 'references', + 'severity', + 'timeline_id', + 'timeline_title', + 'threat', + 'type', + 'version', + // 'lists', + ]; + + const ruleParamsKeys = [ + ...commonRuleParamsKeys, + ...(isMlRule(ruleType) + ? ['anomaly_threshold', 'machine_learning_job_id'] + : ['index', 'filters', 'language', 'query', 'saved_id']), + ].sort(); + + return ruleParamsKeys; +}; + +export const getActionMessageParams = memoizeOne((ruleType: RuleType | undefined): string[] => { + if (!ruleType) { + return []; + } + const actionMessageRuleParams = getActionMessageRuleParams(ruleType); + + return [ + 'state.signals_count', + '{context.results_link}', + ...actionMessageRuleParams.map(param => `context.rule.${param}`), + ]; +}); 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 b2c17fb8d38a8..d228ded5dd741 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 @@ -8,7 +8,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useRef, useState } from 'react'; import { Redirect } from 'react-router-dom'; -import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; +import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules'; import { DETECTION_ENGINE_PAGE_NAME, getDetectionEngineUrl, @@ -20,7 +20,7 @@ import { SpyRoute } from '../../../utils/route/spy_routes'; import { useUserInfo } from '../components/user_info'; import { AllRules } from './all'; -import { ImportRuleModal } from './components/import_rule_modal'; +import { ImportDataModal } from '../../../components/import_data_modal'; import { ReadOnlyCallOut } from './components/read_only_callout'; import { UpdatePrePackagedRulesCallOut } from './components/pre_packaged_rules/update_callout'; import { getPrePackagedRuleStatus, redirectToDetections } from './helpers'; @@ -96,10 +96,20 @@ const RulesPageComponent: React.FC = () => { return ( <> {userHasNoPermissions && } - setShowImportModal(false)} + description={i18n.SELECT_RULE} + errorMessage={i18n.IMPORT_FAILED} + failedDetailed={i18n.IMPORT_FAILED_DETAILED} importComplete={handleRefreshRules} + importData={importRules} + successMessage={i18n.SUCCESSFULLY_IMPORTED_RULES} + showCheckBox={true} + showModal={showImportModal} + submitBtnText={i18n.IMPORT_RULE_BTN_TITLE} + subtitle={i18n.INITIAL_PROMPT_TEXT} + title={i18n.IMPORT_RULE} /> defaultMessage: 'Reload {missingRules} deleted Elastic prebuilt {missingRules, plural, =1 {rule} other {rules}} ', }); + +export const IMPORT_RULE_BTN_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle', + { + defaultMessage: 'Import rule', + } +); + +export const SELECT_RULE = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription', + { + defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine view) to import', + } +); + +export const INITIAL_PROMPT_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', + { + defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', + } +); + +export const OVERWRITE_WITH_SAME_NAME = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', + { + defaultMessage: 'Automatically overwrite saved objects with the same rule ID', + } +); + +export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle', + { + values: { totalRules }, + defaultMessage: + 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', + } + ); + +export const IMPORT_FAILED = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle', + { + defaultMessage: 'Failed to import rules', + } +); + +export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', + { + values: { ruleId, statusCode, message }, + defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', + } + ); 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 447b5dc6325ee..c1db24991c17c 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 @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../plugins/alerting/common'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { Filter } from '../../../../../../../../src/plugins/data/common'; import { RuleType } from '../../../containers/detection_engine/rules/types'; import { FieldValueQueryBar } from './components/query_bar'; @@ -27,6 +29,7 @@ export enum RuleStep { defineRule = 'define-rule', aboutRule = 'about-rule', scheduleRule = 'schedule-rule', + ruleActions = 'rule-actions', } export type RuleStatusType = 'passive' | 'active' | 'valid'; @@ -57,7 +60,6 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; - timeline: FieldValueTimeline; threat: IMitreEnterpriseAttack[]; note: string; } @@ -73,15 +75,22 @@ export interface DefineStepRule extends StepRuleData { machineLearningJobId: string; queryBar: FieldValueQueryBar; ruleType: RuleType; + timeline: FieldValueTimeline; } export interface ScheduleStepRule extends StepRuleData { - enabled: boolean; interval: string; from: string; to?: string; } +export interface ActionsStepRule extends StepRuleData { + actions: AlertAction[]; + enabled: boolean; + kibanaSiemAppUrl?: string; + throttle?: string | null; +} + export interface DefineStepRuleJson { anomaly_threshold?: number; index?: string[]; @@ -90,6 +99,8 @@ export interface DefineStepRuleJson { saved_id?: string; query?: string; language?: string; + timeline_id?: string; + timeline_title?: string; type: RuleType; } @@ -101,23 +112,23 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; - timeline_id?: string; - timeline_title?: string; threat: IMitreEnterpriseAttack[]; note?: string; } export interface ScheduleStepRuleJson { - enabled: boolean; interval: string; from: string; to?: string; meta?: unknown; } -export type MyRule = Omit & { - immutable: boolean; -}; +export interface ActionsStepRuleJson { + actions: RuleAlertAction[]; + enabled: boolean; + throttle?: string | null; + meta?: unknown; +} export interface IMitreAttack { id: string; diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index a087dca38de00..543469e2fddb7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -54,7 +54,7 @@ export const navTabs: SiemNavTab = { [SiemPageName.case]: { id: SiemPageName.case, name: i18n.CASE, - href: getCaseUrl(), + href: getCaseUrl(null), disabled: false, urlKey: 'case', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx index ad2821edde411..3797eae2bb853 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx @@ -6,13 +6,27 @@ import React, { useState } from 'react'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; + import { Sidebar } from './sidebar'; export const StatefulSidebar = React.memo(() => { - const [filterBy, setFilterBy] = useState('favorites'); + const [recentTimelinesFilterBy, setRecentTimelinesFilterBy] = useState( + 'favorites' + ); + const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( + 'recentlyCreated' + ); - return ; + return ( + + ); }); StatefulSidebar.displayName = 'StatefulSidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx index d3b85afe62a2a..52e36b472a0ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx @@ -8,12 +8,17 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { Filters } from '../../../components/recent_timelines/filters'; +import { Filters as RecentCasesFilters } from '../../../components/recent_cases/filters'; +import { Filters as RecentTimelinesFilters } from '../../../components/recent_timelines/filters'; import { ENABLE_NEWS_FEED_SETTING, NEWS_FEED_URL_SETTING } from '../../../../common/constants'; +import { StatefulRecentCases } from '../../../components/recent_cases'; import { StatefulRecentTimelines } from '../../../components/recent_timelines'; import { StatefulNewsFeed } from '../../../components/news_feed'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; +import { DEFAULT_FILTER_OPTIONS } from '../../../containers/case/use_get_cases'; import { SidebarHeader } from '../../../components/sidebar_header'; +import { useCurrentUser } from '../../../lib/kibana'; import { useApolloClient } from '../../../utils/apollo_context'; import * as i18n from '../translations'; @@ -22,35 +27,93 @@ const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; `; +const SidebarSpacerComponent = () => ( + + + +); + +SidebarSpacerComponent.displayName = 'SidebarSpacerComponent'; +const Spacer = React.memo(SidebarSpacerComponent); + export const Sidebar = React.memo<{ - filterBy: FilterMode; - setFilterBy: (filterBy: FilterMode) => void; -}>(({ filterBy, setFilterBy }) => { - const apolloClient = useApolloClient(); - const RecentTimelinesFilters = useMemo( - () => , - [filterBy, setFilterBy] - ); - - return ( - - - {RecentTimelinesFilters} - - - - - - - - - void; + setRecentTimelinesFilterBy: (filterBy: RecentTimelinesFilterMode) => void; +}>( + ({ + recentCasesFilterBy, + recentTimelinesFilterBy, + setRecentCasesFilterBy, + setRecentTimelinesFilterBy, + }) => { + const currentUser = useCurrentUser(); + const apolloClient = useApolloClient(); + const recentCasesFilters = useMemo( + () => ( + - - - ); -}); + ), + [currentUser, recentCasesFilterBy, setRecentCasesFilterBy] + ); + const recentCasesFilterOptions = useMemo( + () => + recentCasesFilterBy === 'myRecentlyReported' && currentUser != null + ? { + ...DEFAULT_FILTER_OPTIONS, + reporters: [ + { + email: currentUser.email, + full_name: currentUser.fullName, + username: currentUser.username, + }, + ], + } + : DEFAULT_FILTER_OPTIONS, + [currentUser, recentCasesFilterBy] + ); + const recentTimelinesFilters = useMemo( + () => ( + + ), + [recentTimelinesFilterBy, setRecentTimelinesFilterBy] + ); + + return ( + + + {recentCasesFilters} + + + + + + + {recentTimelinesFilters} + + + + + + + + + + ); + } +); Sidebar.displayName = 'Sidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts index 5ccd25984bc40..601a629d86e57 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts @@ -26,6 +26,10 @@ export const PAGE_SUBTITLE = i18n.translate('xpack.siem.overview.pageSubtitle', defaultMessage: 'Security Information & Event Management with the Elastic Stack', }); +export const RECENT_CASES = i18n.translate('xpack.siem.overview.recentCasesSidebarTitle', { + defaultMessage: 'Recent cases', +}); + export const RECENT_TIMELINES = i18n.translate('xpack.siem.overview.recentTimelinesSidebarTitle', { defaultMessage: 'Recent timelines', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index 6d30ea58089f0..38462e6526454 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -5,9 +5,10 @@ */ import ApolloClient from 'apollo-client'; -import React from 'react'; +import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; +import { EuiButton } from '@elastic/eui'; import { HeaderPage } from '../../components/header_page'; import { StatefulOpenTimeline } from '../../components/open_timeline'; import { WrapperPage } from '../../components/wrapper_page'; @@ -27,16 +28,26 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const TimelinesPageComponent: React.FC = ({ apolloClient }) => { + const [importCompleteToggle, setImportCompleteToggle] = useState(false); + const onImportTimelineBtnClick = useCallback(() => { + setImportCompleteToggle(true); + }, [setImportCompleteToggle]); return ( <> - + + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + diff --git a/x-pack/legacy/plugins/siem/public/shared_imports.ts b/x-pack/legacy/plugins/siem/public/shared_imports.ts index edd7812b3bd16..c83433ef129c9 100644 --- a/x-pack/legacy/plugins/siem/public/shared_imports.ts +++ b/x-pack/legacy/plugins/siem/public/shared_imports.ts @@ -18,6 +18,9 @@ export { useForm, ValidationFunc, } from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field } from '../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { + Field, + SelectField, +} from '../../../../../src/plugins/es_ui_shared/static/forms/components'; export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers'; export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts index 301960cea33ef..452c615c65aa5 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts @@ -13,11 +13,10 @@ export const tlsSchema = gql` type TlsNode { _id: String timestamp: Date - alternativeNames: [String!] notAfter: [String!] - commonNames: [String!] + subjects: [String!] ja3: [String!] - issuerNames: [String!] + issuers: [String!] } input TlsSortField { field: TlsFields! diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index f42da48f2c1da..e2b365f8bfa5b 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -1861,15 +1861,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -7824,15 +7822,13 @@ export namespace TlsNodeResolvers { timestamp?: TimestampResolver, TypeParent, TContext>; - alternativeNames?: AlternativeNamesResolver, TypeParent, TContext>; - notAfter?: NotAfterResolver, TypeParent, TContext>; - commonNames?: CommonNamesResolver, TypeParent, TContext>; + subjects?: SubjectsResolver, TypeParent, TContext>; ja3?: Ja3Resolver, TypeParent, TContext>; - issuerNames?: IssuerNamesResolver, TypeParent, TContext>; + issuers?: IssuersResolver, TypeParent, TContext>; } export type _IdResolver, Parent = TlsNode, TContext = SiemContext> = Resolver< @@ -7845,17 +7841,12 @@ export namespace TlsNodeResolvers { Parent = TlsNode, TContext = SiemContext > = Resolver; - export type AlternativeNamesResolver< - R = Maybe, - Parent = TlsNode, - TContext = SiemContext - > = Resolver; export type NotAfterResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext > = Resolver; - export type CommonNamesResolver< + export type SubjectsResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext @@ -7865,7 +7856,7 @@ export namespace TlsNodeResolvers { Parent, TContext >; - export type IssuerNamesResolver< + export type IssuersResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts new file mode 100644 index 0000000000000..e14d20e3bc56e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.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 { addTags } from './add_tags'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +describe('add_tags', () => { + test('it should add a rule id as an internal structure', () => { + const tags = addTags([], 'rule-1'); + expect(tags).toEqual([`${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate tags to be created', () => { + const tags = addTags(['tag-1', 'tag-1'], 'rule-1'); + expect(tags).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate internal tags to be created when called two times in a row', () => { + const tags1 = addTags(['tag-1'], 'rule-1'); + const tags2 = addTags(tags1, 'rule-1'); + expect(tags2).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts new file mode 100644 index 0000000000000..6955e57d099be --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.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. + */ + +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const addTags = (tags: string[] = [], ruleAlertId: string): string[] => + Array.from(new Set([...tags, `${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}`])); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts new file mode 100644 index 0000000000000..189c596a77125 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildSignalsSearchQuery } from './build_signals_query'; + +describe('buildSignalsSearchQuery', () => { + it('returns proper query object', () => { + const index = 'index'; + const ruleId = 'ruleId-12'; + const from = '123123123'; + const to = '1123123123'; + + expect( + buildSignalsSearchQuery({ + index, + from, + to, + ruleId, + }) + ).toEqual({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gt: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts new file mode 100644 index 0000000000000..b973d4c5f4e98 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.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. + */ + +interface BuildSignalsSearchQuery { + ruleId: string; + index: string; + from: string; + to: string; +} + +export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignalsSearchQuery) => ({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gt: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts new file mode 100644 index 0000000000000..073251b68f414 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { createNotifications } from './create_notifications'; + +describe('createNotifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with proper params', async () => { + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + await createNotifications({ + alertsClient, + actions: [], + ruleAlertId, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId, + }), + }), + }) + ); + }); + + it('calls the alertsClient with transformed actions', async () => { + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signals_count}} signals' }, + action_type_id: '.slack', + }; + await createNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts new file mode 100644 index 0000000000000..3a1697f1c8afc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; +import { CreateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const createNotifications = async ({ + alertsClient, + actions, + enabled, + ruleAlertId, + interval, + name, + tags, +}: CreateNotificationParams): Promise => + alertsClient.create({ + data: { + name, + tags: addTags(tags, ruleAlertId), + alertTypeId: NOTIFICATIONS_ID, + consumer: APP_ID, + params: { + ruleAlertId, + }, + schedule: { interval }, + enabled, + actions: actions?.map(transformRuleToAlertAction), + throttle: null, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts new file mode 100644 index 0000000000000..7e5c0eaf6286e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteNotifications } from './delete_notifications'; +import { readNotifications } from './read_notifications'; +jest.mock('./read_notifications'); + +describe('deleteNotifications', () => { + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if notification.id and id were null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: undefined, + ruleAlertId, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts new file mode 100644 index 0000000000000..7e244f96f1649 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readNotifications } from './read_notifications'; +import { DeleteNotificationParams } from './types'; + +export const deleteNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: DeleteNotificationParams) => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + if (notification == null) { + return null; + } + + if (notification.id != null) { + await alertsClient.delete({ id: notification.id }); + return notification; + } else if (id != null) { + try { + await alertsClient.delete({ id }); + return notification; + } catch (err) { + if (err.output.statusCode === 404) { + return null; + } else { + throw err; + } + } + } else { + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts new file mode 100644 index 0000000000000..0e9e4a8370ec8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.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. + */ + +import { getFilter } from './find_notifications'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +describe('find_notifications', () => { + test('it returns a full filter with an AND if sent down', () => { + expect(getFilter('alert.attributes.enabled: true')).toEqual( + `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND alert.attributes.enabled: true` + ); + }); + + test('it returns existing filter with no AND when not set', () => { + expect(getFilter(null)).toEqual(`alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts new file mode 100644 index 0000000000000..fcdeda608fe4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FindResult } from '../../../../../../../plugins/alerting/server'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { FindNotificationParams } from './types'; + +export const getFilter = (filter: string | null | undefined) => { + if (filter == null) { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`; + } else { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND ${filter}`; + } +}; + +export const findNotifications = async ({ + alertsClient, + perPage, + page, + fields, + filter, + sortField, + sortOrder, +}: FindNotificationParams): Promise => + alertsClient.find({ + options: { + fields, + page, + perPage, + filter: getFilter(filter), + sortOrder, + sortField, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts new file mode 100644 index 0000000000000..33cee6d074b70 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.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 moment from 'moment'; +import { getNotificationResultsLink } from './utils'; +import { NotificationExecutorOptions } from './types'; +import { parseScheduleDates } from '../signals/utils'; +import { buildSignalsSearchQuery } from './build_signals_query'; + +interface SignalsCountResults { + signalsCount: string; + resultsLink: string; +} + +interface GetSignalsCount { + from: Date | string; + to: Date | string; + ruleAlertId: string; + ruleId: string; + index: string; + kibanaSiemAppUrl: string | undefined; + callCluster: NotificationExecutorOptions['services']['callCluster']; +} + +export const getSignalsCount = async ({ + from, + to, + ruleAlertId, + ruleId, + index, + callCluster, + kibanaSiemAppUrl = '', +}: GetSignalsCount): Promise => { + const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from); + const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to); + + if (!fromMoment || !toMoment) { + throw new Error(`There was an issue with parsing ${from} or ${to} into Moment object`); + } + + const fromInMs = fromMoment.format('x'); + const toInMs = toMoment.format('x'); + + const query = buildSignalsSearchQuery({ + index, + ruleId, + to: toInMs, + from: fromInMs, + }); + + const result = await callCluster('count', query); + const resultsLink = getNotificationResultsLink({ + kibanaSiemAppUrl: `${kibanaSiemAppUrl}`, + id: ruleAlertId, + from: fromInMs, + to: toInMs, + }); + + return { + signalsCount: result.count, + resultsLink, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts new file mode 100644 index 0000000000000..834ad2460959c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readNotifications } from './read_notifications'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { + getNotificationResult, + getFindNotificationsResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; + +class TestError extends Error { + constructor() { + super(); + + this.name = 'CustomError'; + this.output = { statusCode: 404 }; + } + public output: { statusCode: number }; +} + +describe('read_notifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + describe('readNotifications', () => { + test('should return the output from alertsClient if id is set but ruleAlertId is undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(getNotificationResult()); + }); + test('should return null if saved object found by alerts client given id is not alert type', async () => { + const result = getNotificationResult(); + delete result.alertTypeId; + alertsClient.get.mockResolvedValue(result); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws 404 error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new TestError(); + }); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new Error('Test error'); + }); + try { + await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + } catch (exc) { + expect(exc.message).toEqual('Test error'); + } + }); + + test('should return the output from alertsClient if id is set but ruleAlertId is null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: null, + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return the output from alertsClient if id is undefined but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if the output from alertsClient with ruleAlertId set is empty', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(null); + }); + + test('should return the output from alertsClient if id is null but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if id and ruleAlertId are null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: null, + }); + expect(rule).toEqual(null); + }); + + test('should return null if id and ruleAlertId are undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts new file mode 100644 index 0000000000000..87bdd6f3f40e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SanitizedAlert } from '../../../../../../../plugins/alerting/common'; +import { ReadNotificationParams, isAlertType } from './types'; +import { findNotifications } from './find_notifications'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const readNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: ReadNotificationParams): Promise => { + if (id != null) { + try { + const notification = await alertsClient.get({ id }); + if (isAlertType(notification)) { + return notification; + } else { + return null; + } + } catch (err) { + if (err?.output?.statusCode === 404) { + return null; + } else { + // throw non-404 as they would be 500 or other internal errors + throw err; + } + } + } else if (ruleAlertId != null) { + const notificationFromFind = await findNotifications({ + alertsClient, + filter: `alert.attributes.tags: "${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}"`, + page: 1, + }); + if (notificationFromFind.data.length === 0 || !isAlertType(notificationFromFind.data[0])) { + return null; + } else { + return notificationFromFind.data[0]; + } + } else { + // should never get here, and yet here we are. + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts new file mode 100644 index 0000000000000..50ac10347e062 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getResult } from '../routes/__mocks__/request_responses'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { buildSignalsSearchQuery } from './build_signals_query'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { NotificationExecutorOptions } from './types'; +jest.mock('./build_signals_query'); + +describe('rules_notification_alert_type', () => { + let payload: NotificationExecutorOptions; + let alert: ReturnType; + let alertInstanceMock: Record; + let alertInstanceFactoryMock: () => AlertInstance; + let savedObjectsClient: ReturnType; + let logger: ReturnType; + let callClusterMock: jest.Mock; + + beforeEach(() => { + alertInstanceMock = { + scheduleActions: jest.fn(), + replaceState: jest.fn(), + }; + alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); + alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); + callClusterMock = jest.fn(); + savedObjectsClient = savedObjectsClientMock.create(); + logger = loggerMock.create(); + + payload = { + alertId: '1111', + services: { + savedObjectsClient, + alertInstanceFactory: alertInstanceFactoryMock, + callCluster: callClusterMock, + }, + params: { ruleAlertId: '2222' }, + state: {}, + spaceId: '', + name: 'name', + tags: [], + startedAt: new Date('2019-12-14T16:40:33.400Z'), + previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), + createdBy: 'elastic', + updatedBy: 'elastic', + }; + + alert = rulesNotificationAlertType({ + logger, + }); + }); + + describe('executor', () => { + it('throws an error if rule alert was not found', async () => { + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + attributes: {}, + type: 'type', + references: [], + }); + await alert.executor(payload); + expect(logger.error).toHaveBeenCalledWith( + `Saved object for alert ${payload.params.ruleAlertId} was not found` + ); + }); + + it('should call buildSignalsSearchQuery with proper params', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(buildSignalsSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + from: '1576255233400', + index: '.siem-signals', + ruleId: 'rule-1', + to: '1576341633400', + }) + ); + }); + + it('should not call alertInstanceFactory if signalsCount was 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).not.toHaveBeenCalled(); + }); + + it('should call scheduleActions if signalsCount was greater than 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).toHaveBeenCalled(); + expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ signals_count: 10 }) + ); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + rule: expect.objectContaining({ + name: ruleAlert.name, + }), + }) + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts new file mode 100644 index 0000000000000..32e64138ff6e0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.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 { Logger } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +import { NotificationAlertTypeDefinition } from './types'; +import { getSignalsCount } from './get_signals_count'; +import { RuleAlertAttributes } from '../signals/types'; +import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; +import { scheduleNotificationActions } from './schedule_notification_actions'; + +export const rulesNotificationAlertType = ({ + logger, +}: { + logger: Logger; +}): NotificationAlertTypeDefinition => ({ + id: NOTIFICATIONS_ID, + name: 'SIEM Notifications', + actionGroups: siemRuleActionGroups, + defaultActionGroupId: 'default', + validate: { + params: schema.object({ + ruleAlertId: schema.string(), + }), + }, + async executor({ startedAt, previousStartedAt, alertId, services, params }) { + const ruleAlertSavedObject = await services.savedObjectsClient.get( + 'alert', + params.ruleAlertId + ); + + if (!ruleAlertSavedObject.attributes.params) { + logger.error(`Saved object for alert ${params.ruleAlertId} was not found`); + return; + } + + const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes; + const ruleParams = { ...ruleAlertParams, name: ruleName, id: ruleAlertSavedObject.id }; + + const { signalsCount, resultsLink } = await getSignalsCount({ + from: previousStartedAt ?? `now-${ruleParams.interval}`, + to: startedAt, + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaSiemAppUrl: ruleAlertParams.meta?.kibanaSiemAppUrl as string, + ruleAlertId: ruleAlertSavedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${ruleParams.name}", id: "${params.ruleAlertId}", rule_id: "${ruleParams.ruleId}" in "${ruleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ alertInstance, signalsCount, resultsLink, ruleParams }); + } + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts new file mode 100644 index 0000000000000..b858b25377ffe --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapKeys, snakeCase } from 'lodash/fp'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { RuleTypeParams } from '../types'; + +type NotificationRuleTypeParams = RuleTypeParams & { + name: string; + id: string; +}; + +interface ScheduleNotificationActions { + alertInstance: AlertInstance; + signalsCount: string; + resultsLink: string; + ruleParams: NotificationRuleTypeParams; +} + +export const scheduleNotificationActions = ({ + alertInstance, + signalsCount, + resultsLink, + ruleParams, +}: ScheduleNotificationActions): AlertInstance => + alertInstance + .replaceState({ + signals_count: signalsCount, + }) + .scheduleActions('default', { + results_link: resultsLink, + rule: mapKeys(snakeCase, ruleParams), + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts new file mode 100644 index 0000000000000..4fce037b483d5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.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 { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { isAlertTypes, isNotificationAlertExecutor } from './types'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; + +describe('types', () => { + it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { + expect(isAlertTypes([getNotificationResult()])).toEqual(true); + }); + + it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { + expect(isAlertTypes([getResult()])).toEqual(false); + }); + + it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { + expect( + isNotificationAlertExecutor(rulesNotificationAlertType({ logger: loggerMock.create() })) + ).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts new file mode 100644 index 0000000000000..edcd821353bc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AlertsClient, + PartialAlert, + AlertType, + State, + AlertExecutorOptions, +} from '../../../../../../../plugins/alerting/server'; +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; + +export interface RuleNotificationAlertType extends Alert { + params: { + ruleAlertId: string; + }; +} + +export interface FindNotificationParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + filter?: string; + fields?: string[]; + sortOrder?: 'asc' | 'desc'; +} + +export interface FindNotificationsRequestParams { + per_page: number; + page: number; + search?: string; + sort_field?: string; + filter?: string; + fields?: string[]; + sort_order?: 'asc' | 'desc'; +} + +export interface Clients { + alertsClient: AlertsClient; +} + +export type UpdateNotificationParams = Omit & { + actions: RuleAlertAction[]; + id?: string; + tags?: string[]; + interval: string | null; + ruleAlertId: string; +} & Clients; + +export type DeleteNotificationParams = Clients & { + id?: string; + ruleAlertId?: string; +}; + +export interface NotificationAlertParams { + actions: RuleAlertAction[]; + enabled: boolean; + ruleAlertId: string; + interval: string; + name: string; + tags?: string[]; + throttle?: null; +} + +export type CreateNotificationParams = NotificationAlertParams & Clients; + +export interface ReadNotificationParams { + alertsClient: AlertsClient; + id?: string | null; + ruleAlertId?: string | null; +} + +export const isAlertTypes = ( + partialAlert: PartialAlert[] +): partialAlert is RuleNotificationAlertType[] => { + return partialAlert.every(rule => isAlertType(rule)); +}; + +export const isAlertType = ( + partialAlert: PartialAlert +): partialAlert is RuleNotificationAlertType => { + return partialAlert.alertTypeId === NOTIFICATIONS_ID; +}; + +export type NotificationExecutorOptions = Omit & { + params: { + ruleAlertId: string; + }; +}; + +// This returns true because by default a NotificationAlertTypeDefinition is an AlertType +// since we are only increasing the strictness of params. +export const isNotificationAlertExecutor = ( + obj: NotificationAlertTypeDefinition +): obj is AlertType => { + return true; +}; + +export type NotificationAlertTypeDefinition = Omit & { + executor: ({ services, params, state }: NotificationExecutorOptions) => Promise; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts new file mode 100644 index 0000000000000..4c077dd9fc1fb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { updateNotifications } from './update_notifications'; +import { readNotifications } from './read_notifications'; +import { createNotifications } from './create_notifications'; +import { getNotificationResult } from '../routes/__mocks__/request_responses'; +jest.mock('./read_notifications'); +jest.mock('./create_notifications'); + +describe('updateNotifications', () => { + const notification = getNotificationResult(); + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should update the existing notification if interval provided', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId: 'new-rule-id', + }), + }), + }) + ); + }); + + it('should create a new notification if did not exist', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const params = { + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }; + + await updateNotifications(params); + + expect(createNotifications).toHaveBeenCalledWith(expect.objectContaining(params)); + }); + + it('should delete notification if notification was found and interval is null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: null, + name: '', + tags: [], + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + }) + ); + }); + + it('should call the alertsClient with transformed actions', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signals_count}} signals' }, + action_type_id: '.slack', + }; + await updateNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); + + it('returns null if notification was not found and interval was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + const result = await updateNotifications({ + alertsClient, + actions: [], + enabled: true, + id: notification.id, + ruleAlertId, + name: notification.name, + tags: notification.tags, + interval: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts new file mode 100644 index 0000000000000..3197d21c0e95a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.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 { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { readNotifications } from './read_notifications'; +import { UpdateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { createNotifications } from './create_notifications'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const updateNotifications = async ({ + alertsClient, + actions, + enabled, + id, + ruleAlertId, + name, + tags, + interval, +}: UpdateNotificationParams): Promise => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + + if (interval && notification) { + const result = await alertsClient.update({ + id: notification.id, + data: { + tags: addTags(tags, ruleAlertId), + name, + schedule: { + interval, + }, + actions: actions?.map(transformRuleToAlertAction), + params: { + ruleAlertId, + }, + throttle: null, + }, + }); + return result; + } + + if (interval && !notification) { + const result = await createNotifications({ + alertsClient, + enabled, + tags, + name, + interval, + actions, + ruleAlertId, + }); + return result; + } + + if (!interval && notification) { + await alertsClient.delete({ id: notification.id }); + return null; + } + + return null; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts new file mode 100644 index 0000000000000..0d363e1f6f3c2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.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 { getNotificationResultsLink } from './utils'; + +describe('utils', () => { + it('getNotificationResultsLink', () => { + const resultLink = getNotificationResultsLink({ + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + id: 'notification-id', + from: '00000', + to: '1111', + }); + expect(resultLink).toEqual( + `http://localhost:5601/app/siem#/detections/rules/id/notification-id?timerange=(global:(linkTo:!(timeline),timerange:(from:00000,kind:absolute,to:1111)),timeline:(linkTo:!(global),timerange:(from:00000,kind:absolute,to:1111)))` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts new file mode 100644 index 0000000000000..b8a3c4199c4f0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getNotificationResultsLink = ({ + kibanaSiemAppUrl, + id, + from, + to, +}: { + kibanaSiemAppUrl: string; + id: string; + from: string; + to: string; +}) => + `${kibanaSiemAppUrl}#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts index ebf6b3ae79ea8..2e5c29bc0221a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -12,11 +12,13 @@ import { } from '../../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../../plugins/actions/server/mocks'; +import { licensingMock } from '../../../../../../../../plugins/licensing/server/mocks'; const createMockClients = () => ({ actionsClient: actionsClientMock.create(), alertsClient: alertsClientMock.create(), clusterClient: elasticsearchServiceMock.createScopedClusterClient(), + licensing: { license: licensingMock.createLicenseMock() }, savedObjectsClient: savedObjectsClientMock.create(), siemClient: { signalsIndex: 'mockSignalsIndex' }, }); @@ -33,6 +35,7 @@ const createRequestContextMock = ( elasticsearch: { ...coreContext.elasticsearch, dataClient: clients.clusterClient }, savedObjects: { client: clients.savedObjectsClient }, }, + licensing: clients.licensing, siem: { getSiemClient: jest.fn(() => clients.siemClient) }, } as unknown) as RequestHandlerContext; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 0e0ab58a7a199..0ecc1aa28e7e0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -28,6 +28,7 @@ import { } from '../../rules/types'; import { RuleAlertParamsRest, PrepackagedRules } from '../../types'; import { requestMock } from './request'; +import { RuleNotificationAlertType } from '../../notifications/types'; export const mockPrepackagedRule = (): PrepackagedRules => ({ rule_id: 'rule-1', @@ -204,11 +205,11 @@ export const getPrepackagedRulesStatusRequest = () => path: `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`, }); -export interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; - data: RuleAlertType[]; + data: T[]; } export const getEmptyFindResult = (): FindHit => ({ @@ -294,17 +295,50 @@ export const getCreateRequest = () => body: typicalPayload(), }); -export const createMlRuleRequest = () => { +export const typicalMlRulePayload = () => { const { query, language, index, ...mlParams } = typicalPayload(); + return { + ...mlParams, + type: 'machine_learning', + anomaly_threshold: 58, + machine_learning_job_id: 'typical-ml-job-id', + }; +}; + +export const createMlRuleRequest = () => { + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); +}; + +export const createBulkMlRuleRequest = () => { + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: [typicalMlRulePayload()], + }); +}; + +export const createRuleWithActionsRequest = () => { + const payload = typicalPayload(); + return requestMock.create({ method: 'post', path: DETECTION_ENGINE_RULES_URL, body: { - ...mlParams, - type: 'machine_learning', - anomaly_threshold: 50, - machine_learning_job_id: 'some-uuid', + ...payload, + throttle: '5m', + actions: [ + { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signals_count}} signals' }, + action_type_id: '.slack', + }, + ], }, }); }; @@ -616,3 +650,46 @@ export const getEmptyIndex = (): { _shards: Partial } => ({ export const getNonEmptyIndex = (): { _shards: Partial } => ({ _shards: { total: 1 }, }); + +export const getNotificationResult = (): RuleNotificationAlertType => ({ + id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba', + name: 'Notification for Rule Test', + tags: ['__internal_rule_alert_id:85b64e8a-2e40-4096-86af-5ac172c10825'], + alertTypeId: 'siem.notifications', + consumer: 'siem', + params: { + ruleAlertId: '85b64e8a-2e40-4096-86af-5ac172c10825', + }, + schedule: { + interval: '5m', + }, + enabled: true, + actions: [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ], + throttle: null, + apiKey: null, + apiKeyOwner: 'elastic', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: new Date('2020-03-21T11:15:13.530Z'), + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '62b3a130-6b70-11ea-9ce9-6b9818c4cbd7', + updatedAt: new Date('2020-03-21T12:37:08.730Z'), +}); + +export const getFindNotificationsResultWithSingleHit = (): FindHit => ({ + page: 1, + perPage: 1, + total: 1, + data: [getNotificationResult()], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts index 13d75cc44992c..a2485ec477453 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -23,6 +23,21 @@ export const getSimpleRule = (ruleId = 'rule-1'): Partial = query: 'user.name: root or user.name: admin', }); +/** + * This is a typical ML rule for testing + * @param ruleId + */ +export const getSimpleMlRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Simple Rule Query', + description: 'Simple Rule Query', + risk_score: 1, + rule_id: ruleId, + severity: 'high', + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_job_id', +}); + /** * This is a typical simple rule for testing that is easy for most basic testing * @param ruleId diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index e2af678c828e6..32b8eca298229 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -13,6 +13,7 @@ import { getFindResultWithSingleHit, getEmptyFindResult, getResult, + createBulkMlRuleRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; @@ -56,6 +57,22 @@ describe('create_rules_bulk', () => { }); describe('unhappy paths', () => { + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(createBulkMlRuleRequest(), context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); + it('returns an error object if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const response = await server.inject(getReadBulkRequest(), context); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 4ffa29c385f28..1ca9f7ef9075e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -19,6 +19,7 @@ import { createBulkErrorObject, buildRouteValidation, buildSiemResponse, + validateLicenseForRuleType, } from '../utils'; import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; @@ -90,6 +91,8 @@ export const createRulesBulkRoute = (router: IRouter) => { } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + const finalIndex = outputIndex ?? siemClient.signalsIndex; const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); if (!indexExists) { 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 1a4e19c2047b5..4da879d12f809 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 @@ -15,10 +15,13 @@ import { getEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, + createRuleWithActionsRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +import { createNotifications } from '../../notifications/create_notifications'; +jest.mock('../../notifications/create_notifications'); describe('create_rules', () => { let server: ReturnType; @@ -56,6 +59,13 @@ describe('create_rules', () => { expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + + it('returns 200 if license is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(getCreateRequest(), context); + expect(response.status).toEqual(200); + }); }); describe('creating an ML Rule', () => { @@ -63,6 +73,29 @@ describe('create_rules', () => { const response = await server.inject(createMlRuleRequest(), context); expect(response.status).toEqual(200); }); + + it('rejects the request if licensing is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(createMlRuleRequest(), context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); + }); + + describe('creating a Notification if throttle and actions were provided ', () => { + it('is successful', async () => { + const response = await server.inject(createRuleWithActionsRequest(), context); + expect(response.status).toEqual(200); + expect(createNotifications).toHaveBeenCalledWith( + expect.objectContaining({ + ruleAlertId: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); }); describe('unhappy paths', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index cee9054cf922e..edf37bcb8dbe7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -16,7 +16,13 @@ import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; +import { createNotifications } from '../../notifications/create_notifications'; export const createRulesRoute = (router: IRouter): void => { router.post( @@ -65,6 +71,7 @@ export const createRulesRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } @@ -131,6 +138,18 @@ export const createRulesRoute = (router: IRouter): void => { version: 1, lists, }); + + if (throttle && actions.length) { + await createNotifications({ + alertsClient, + enabled, + name, + interval, + actions, + ruleAlertId: createdRule.id, + }); + } + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c56f34588cbc6..85cfeefdceead 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -16,6 +16,7 @@ import { DeleteRulesRequestParams, } from '../../rules/types'; import { deleteRules } from '../../rules/delete_rules'; +import { deleteNotifications } from '../../notifications/delete_notifications'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; type Config = RouteConfig; @@ -57,6 +58,7 @@ export const deleteRulesBulkRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 753b281dbc09e..6fd50abd9364a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -16,6 +16,7 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { deleteNotifications } from '../../notifications/delete_notifications'; export const deleteRulesRoute = (router: IRouter) => { router.delete( @@ -52,6 +53,7 @@ export const deleteRulesRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index f6e1cf6e2420c..aacf83b9ec58a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -9,6 +9,8 @@ import { ruleIdsToNdJsonString, rulesToNdJsonString, getSimpleRuleWithId, + getSimpleRule, + getSimpleMlRule, } from '../__mocks__/utils'; import { getImportRulesRequest, @@ -102,6 +104,30 @@ describe('import_rules_route', () => { }); describe('unhappy paths', () => { + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const rules = [getSimpleRule(), getSimpleMlRule('rule-2')]; + const hapiStreamWithMlRule = buildHapiStream(rulesToNdJsonString(rules)); + request = getImportRulesRequest(hapiStreamWithMlRule); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + errors: [ + { + error: { + message: + 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-2', + }, + ], + success: false, + success_count: 1, + }); + }); + test('returns error if createPromiseFromStreams throws error', async () => { jest .spyOn(createRulesStreamFromNdJson, 'createRulesStreamFromNdJson') diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4a5ea33025d49..2e6c72a87ec7f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -24,6 +24,7 @@ import { isImportRegular, transformError, buildSiemResponse, + validateLicenseForRuleType, } from '../utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { ImportRuleAlertRest } from '../../types'; @@ -146,6 +147,11 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config } = parsedRule; try { + validateLicenseForRuleType({ + license: context.licensing.license, + ruleType: type, + }); + const signalsIndex = siemClient.signalsIndex; const indexExists = await getIndexExists( clusterClient.callAsCurrentUser, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 4c00cfa51c8ee..a1f39936dd674 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit, getPatchBulkRequest, getResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; @@ -88,6 +89,27 @@ describe('patch_rules_bulk', () => { expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + + it('rejects patching of an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [typicalMlRulePayload()], + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index a80f3fee6b433..645dbdadf8cab 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -10,7 +10,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, PatchRuleAlertParamsRest, } from '../../rules/types'; -import { transformBulkError, buildRouteValidation, buildSiemResponse } from '../utils'; +import { + transformBulkError, + buildRouteValidation, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema'; @@ -80,6 +85,10 @@ export const patchRulesBulkRoute = (router: IRouter) => { } = payloadRule; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { + if (type) { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + } + const rule = await patchRules({ alertsClient, actionsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 07519733db291..1e344d8ea7e31 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -13,6 +13,7 @@ import { typicalPayload, getFindResultWithSingleHit, nonRuleFindResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; @@ -109,6 +110,22 @@ describe('patch_rules', () => { }) ); }); + + it('rejects patching a rule to ML if licensing is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); + const response = await server.inject(request, context); + + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index c5ecb109f4595..620bcd8fc17b0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -12,7 +12,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { patchRulesSchema } from '../schemas/patch_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; @@ -65,6 +70,10 @@ export const patchRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + if (type) { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + } + if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index d530866edaf0d..611b38ccbae8b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit, getUpdateBulkRequest, getFindResultStatus, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; @@ -83,6 +84,27 @@ describe('update_rules_bulk', () => { expect(response.status).toEqual(200); expect(response.body).toEqual(expected); }); + + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [typicalMlRulePayload()], + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6c3c8dffa3dfa..4abeb840c8c0a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -12,7 +12,12 @@ import { } from '../../rules/types'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; -import { buildRouteValidation, transformBulkError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformBulkError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; @@ -83,6 +88,8 @@ export const updateRulesBulkRoute = (router: IRouter) => { const finalIndex = outputIndex ?? siemClient.signalsIndex; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + const rule = await updateRules({ alertsClient, actionsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index a15f1ca9b044e..717f2cc4a52fe 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -13,6 +13,7 @@ import { getFindResultWithSingleHit, getFindResultStatusEmpty, nonRuleFindResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; @@ -88,6 +89,22 @@ describe('update_rules', () => { status_code: 500, }); }); + + it('rejects the request if licensing is not adequate', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7e56c32ade92a..f0d5f08c5f636 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -11,11 +11,17 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; +import { updateNotifications } from '../../notifications/update_notifications'; export const updateRulesRoute = (router: IRouter) => { router.put( @@ -66,6 +72,8 @@ export const updateRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } @@ -117,7 +125,17 @@ export const updateRulesRoute = (router: IRouter) => { version, lists, }); + if (rule != null) { + await updateNotifications({ + alertsClient, + actions, + enabled, + ruleAlertId: rule.id, + interval: throttle, + name, + }); + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index e0ecbdedaac7c..ca0d133627210 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -19,7 +19,12 @@ import { isRuleStatusFindTypes, isRuleStatusSavedObjectType, } from '../../rules/types'; -import { OutputRuleAlertRest, ImportRuleAlertRest, RuleAlertParamsRest } from '../../types'; +import { + OutputRuleAlertRest, + ImportRuleAlertRest, + RuleAlertParamsRest, + RuleType, +} from '../../types'; import { createBulkErrorObject, BulkError, @@ -29,7 +34,7 @@ import { OutputError, } from '../utils'; import { hasListsFeature } from '../../feature_flags'; -import { transformAlertToRuleAction } from '../../rules/transform_actions'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; @@ -295,3 +300,5 @@ export const getTupleDuplicateErrorsAndUniqueRules = ( return [Array.from(errors.values()), Array.from(rulesAcc.values())]; }; + +export const isMlRule = (ruleType: RuleType) => ruleType === 'machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 2b18e1b9bf52c..b10627d151fa2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -5,7 +5,8 @@ */ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; -import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, PrepackagedRules } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index d9c3055512815..08bd01ee9a1a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index ffb49896ef7c7..c8e5bb981f921 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -10,7 +10,8 @@ import { importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, ImportRuleAlertRest } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index 42945e0970cba..45b5028f392b9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index db3709cd6b126..6f6beea7fa5fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index fdb1cd148c7fa..9efe4e491968b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -19,9 +19,11 @@ import { transformImportError, convertToSnakeCase, SiemResponseFactory, + validateLicenseForRuleType, } from './utils'; import { responseMock } from './__mocks__'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; +import { licensingMock } from '../../../../../../../plugins/licensing/server/mocks'; describe('utils', () => { beforeAll(() => { @@ -359,4 +361,36 @@ describe('utils', () => { ); }); }); + + describe('validateLicenseForRuleType', () => { + let licenseMock: ReturnType; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + }); + + it('throws a BadRequestError if operating on an ML Rule with an insufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) + ).toThrowError(BadRequestError); + }); + + it('does not throw if operating on an ML Rule with a sufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(true); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) + ).not.toThrowError(BadRequestError); + }); + + it('does not throw if operating on a query rule', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'query' }) + ).not.toThrowError(BadRequestError); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index 79c2f47658f7e..90c7d4a07ddf8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -7,13 +7,18 @@ import Boom from 'boom'; import Joi from 'joi'; import { has, snakeCase } from 'lodash/fp'; +import { i18n } from '@kbn/i18n'; import { RouteValidationFunction, KibanaResponseFactory, CustomHttpResponseOptions, } from '../../../../../../../../src/core/server'; +import { ILicense } from '../../../../../../../plugins/licensing/server'; +import { MINIMUM_ML_LICENSE } from '../../../../common/constants'; import { BadRequestError } from '../errors/bad_request_error'; +import { RuleType } from '../types'; +import { isMlRule } from './rules/utils'; export interface OutputError { message: string; @@ -289,3 +294,28 @@ export const convertToSnakeCase = >( return { ...acc, [newKey]: obj[item] }; }, {}); }; + +/** + * Checks the current Kibana License against the rule under operation. + * + * @param license ILicense representing the user license + * @param ruleType the type of the current rule + * + * @throws BadRequestError if rule and license are incompatible + */ +export const validateLicenseForRuleType = ({ + license, + ruleType, +}: { + license: ILicense; + ruleType: RuleType; +}) => { + if (isMlRule(ruleType) && !license.hasAtLeast(MINIMUM_ML_LICENSE)) { + const message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { + defaultMessage: + 'Your license does not support machine learning. Please upgrade your license.', + }); + + throw new BadRequestError(message); + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index db70b90d5a17c..a45b28ba3e105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -6,12 +6,12 @@ import { Alert } from '../../../../../../../plugins/alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; -export const createRules = ({ +export const createRules = async ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... actions, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts new file mode 100644 index 0000000000000..38fc1dc5d1930 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteRules } from './delete_rules'; +import { readRules } from './read_rules'; +jest.mock('./read_rules'); + +describe('deleteRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readRules as jest.Mock).mockResolvedValue(null); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if ruleId and id was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: undefined, + ruleId: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index b424d2912ebc8..cd18bee6f606f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { patchRules } from './patch_rules'; describe('patchRules', () => { @@ -21,6 +21,59 @@ describe('patchRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); const params = { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 5b6fd08a9ea89..5394af526c917 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,12 +6,12 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; -import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts index 862ea9d2dcbe5..38a883329318b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts @@ -8,7 +8,7 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; -class TestError extends Error { +export class TestError extends Error { constructor() { // Pass remaining arguments (including vendor specific ones) to parent constructor super(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts index 967a32df20c3b..af00816abfc3d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; describe('updateRules', () => { @@ -21,6 +21,59 @@ describe('updateRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index a80f986482010..72cbc959c0105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -5,13 +5,13 @@ */ import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index adbd5f81d372a..f485769dffabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,7 +8,8 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index e94ca18b186e4..1de80ca0b7eaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,7 +5,8 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams, OutputRuleAlertRest } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 95adb90172404..66e9f42061658 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -9,7 +9,8 @@ import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { singleBulkCreate } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts index b49f43ce9e7ac..86d1278031695 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts @@ -510,6 +510,20 @@ describe('get_filter', () => { ).rejects.toThrow('savedId parameter should be defined'); }); + test('throws on machine learning query', async () => { + await expect( + getFilter({ + type: 'machine_learning', + filters: undefined, + language: undefined, + query: undefined, + savedId: 'some-id', + services: servicesMock, + index: undefined, + }) + ).rejects.toThrow('Unsupported Rule of type "machine_learning" supplied to getFilter'); + }); + test('it works with references and does not add indexes', () => { const esQuery = getQueryFilter( '(event.module:suricata and event.kind:alert) and suricata.eve.alert.signature_id: (2610182 or 2610183 or 2610184 or 2610185 or 2610186 or 2610187)', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index a12778d5b8f16..4f1a187a82937 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,8 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 89dcd3274ebed..c004b3d0edd1c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -5,13 +5,17 @@ */ import { Logger } from 'src/core/server'; -import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; +import { + SIGNALS_ID, + DEFAULT_SEARCH_AFTER_PAGE_SIZE, + NOTIFICATION_THROTTLE_RULE, +} from '../../../../common/constants'; import { buildEventsSearchQuery } from './build_events_query'; import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; -import { SignalRuleAlertTypeDefinition, AlertAttributes } from './types'; +import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; import { getGapBetweenRuns } from './utils'; import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object'; import { signalParamsSchema } from './signal_params_schema'; @@ -22,6 +26,8 @@ import { getCurrentStatusSavedObject } from './get_current_status_saved_object'; import { writeCurrentStatusSucceeded } from './write_current_status_succeeded'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { getSignalsCount } from '../notifications/get_signals_count'; +import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; export const signalRulesAlertType = ({ logger, @@ -46,6 +52,7 @@ export const signalRulesAlertType = ({ index, filters, language, + meta, machineLearningJobId, outputIndex, savedId, @@ -53,7 +60,10 @@ export const signalRulesAlertType = ({ to, type, } = params; - const savedObject = await services.savedObjectsClient.get('alert', alertId); + const savedObject = await services.savedObjectsClient.get( + 'alert', + alertId + ); const ruleStatusSavedObjects = await getRuleStatusSavedObjects({ alertId, @@ -76,6 +86,7 @@ export const signalRulesAlertType = ({ enabled, schedule: { interval }, throttle, + params: ruleParams, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; @@ -199,6 +210,37 @@ export const signalRulesAlertType = ({ } if (creationSucceeded) { + if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) { + const notificationRuleParams = { + ...ruleParams, + name, + id: savedObject.id, + }; + const { signalsCount, resultsLink } = await getSignalsCount({ + from: `now-${interval}`, + to: 'now', + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaSiemAppUrl: meta.kibanaSiemAppUrl as string, + ruleAlertId: savedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${notificationRuleParams.name}", id: "${notificationRuleParams.ruleId}", rule_id: "${notificationRuleParams.ruleId}" in "${notificationRuleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ + alertInstance, + signalsCount, + resultsLink, + ruleParams: notificationRuleParams, + }); + } + } + logger.debug( `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 333a938e09d45..e2e4471f609ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,7 +8,8 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 06acff825f68e..93c48ed38c7c4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleAlertParams, OutputRuleAlertRest } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -159,3 +160,7 @@ export interface AlertAttributes { }; throttle: string | null; } + +export interface RuleAlertAttributes extends AlertAttributes { + params: RuleAlertParams; +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 2cbdc7db3ba64..aae8763a7ea39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; import { ListsDefaultArraySchema } from './routes/schemas/types/lists_default_array'; +import { RuleAlertAction } from '../../../common/detection_engine/types'; export type PartialFilter = Partial; @@ -24,10 +24,6 @@ export interface ThreatParams { technique: IMitreAttack[]; } -export type RuleAlertAction = Omit & { - action_type_id: string; -}; - // Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. // TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types // We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove @@ -56,7 +52,7 @@ export interface RuleAlertParams { query: string | undefined | null; references: string[]; savedId?: string | undefined | null; - meta: Record | undefined | null; + meta: Record | undefined | null; severity: string; tags: string[]; to: string; @@ -123,6 +119,7 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { created_by: string | undefined | null; updated_by: string | undefined | null; immutable: boolean; + throttle: string | undefined | null; }; export type ImportRuleAlertRest = Omit & { diff --git a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts index b6a43fc523adb..23162f38bffba 100644 --- a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts @@ -141,6 +141,8 @@ export class Note { } // Update new note + + const existingNote = await this.getSavedNote(request, noteId); return { code: 200, message: 'success', @@ -150,7 +152,7 @@ export class Note { noteId, pickSavedNote(noteId, note, request.user), { - version: version || undefined, + version: existingNote.version || undefined, } ) ), diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index 9ea950e8a443b..a95c1da197f57 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -89,7 +89,7 @@ export class PinnedEvent { public async persistPinnedEventOnTimeline( request: FrameworkRequest, - pinnedEventId: string | null, + pinnedEventId: string | null, // pinned event saved object id eventId: string, timelineId: string | null ): Promise { @@ -116,6 +116,7 @@ export class PinnedEvent { const isPinnedAlreadyExisting = allPinnedEventId.filter( pinnedEvent => pinnedEvent.eventId === eventId ); + if (isPinnedAlreadyExisting.length === 0) { const savedPinnedEvent: SavedPinnedEvent = { eventId, @@ -204,7 +205,7 @@ export const convertSavedObjectToSavedPinnedEvent = ( // then this interface does not allow types without index signature // this is limiting us with our type for now so the easy way was to use any -const pickSavedPinnedEvent = ( +export const pickSavedPinnedEvent = ( pinnedEventId: string | null, savedPinnedEvent: SavedPinnedEvent, userInfo: AuthenticatedUser | null diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts new file mode 100644 index 0000000000000..5373570a4f8cc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Transform } from 'stream'; +import { + createConcatStream, + createSplitStream, + createMapStream, +} from '../../../../../../../src/legacy/utils'; +import { + parseNdjsonStrings, + filterExportedCounts, + createLimitStream, +} from '../detection_engine/rules/create_rules_stream_from_ndjson'; +import { importTimelinesSchema } from './routes/schemas/import_timelines_schema'; +import { BadRequestError } from '../detection_engine/errors/bad_request_error'; +import { ImportTimelineResponse } from './routes/utils/import_timelines'; + +export const validateTimelines = (): Transform => { + return createMapStream((obj: ImportTimelineResponse) => { + if (!(obj instanceof Error)) { + const validated = importTimelinesSchema.validate(obj); + if (validated.error != null) { + return new BadRequestError(validated.error.message); + } else { + return validated.value; + } + } else { + return obj; + } + }); +}; + +export const createTimelinesStreamFromNdJson = (ruleLimit: number) => { + return [ + createSplitStream('\n'), + parseNdjsonStrings(), + filterExportedCounts(), + validateTimelines(), + createLimitStream(ruleLimit), + createConcatStream([]), + ]; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts new file mode 100644 index 0000000000000..74d3744e29299 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { omit } from 'lodash/fp'; + +export const mockDuplicateIdErrors = []; + +export const mockParsedObjects = [ + { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [Object] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [ + { + noteId: '73ac2370-6bc2-11ea-a90b-f5341fb7a189', + version: 'WzExMjgsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829349563, + createdBy: 'angela', + updated: 1584829349563, + updatedBy: 'angela', + }, + { + noteId: 'f7b71620-6bc2-11ea-a0b6-33c7b2a78885', + version: 'WzExMzUsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829571092, + createdBy: 'angela', + updated: 1584829571092, + updatedBy: 'angela', + }, + ], + globalNotes: [ + { + noteId: 'd2649d40-6bc5-11ea-86f0-5db0048c6086', + version: 'WzExNjQsMV0=', + note: 'global', + timelineId: 'd123dfe0-6bc5-11ea-86f0-5db0048c6086', + created: 1584830796969, + createdBy: 'angela', + updated: 1584830796969, + updatedBy: 'angela', + }, + ], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], + }, +]; + +export const mockUniqueParsedObjects = [ + { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [ + { + noteId: '73ac2370-6bc2-11ea-a90b-f5341fb7a189', + version: 'WzExMjgsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note1', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829349563, + createdBy: 'angela', + updated: 1584829349563, + updatedBy: 'angela', + }, + { + noteId: 'f7b71620-6bc2-11ea-a0b6-33c7b2a78885', + version: 'WzExMzUsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829571092, + createdBy: 'angela', + updated: 1584829571092, + updatedBy: 'angela', + }, + ], + globalNotes: [ + { + noteId: 'd2649d40-6bc5-11ea-86f0-5db0048c6086', + version: 'WzExNjQsMV0=', + note: 'global', + timelineId: 'd123dfe0-6bc5-11ea-86f0-5db0048c6086', + created: 1584830796969, + createdBy: 'angela', + updated: 1584830796969, + updatedBy: 'angela', + }, + ], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], + }, +]; + +export const mockGetTimelineValue = { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + noteIds: [], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], +}; + +export const mockParsedTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + mockUniqueParsedObjects[0] +); + +export const mockConfig = { + get: () => { + return 100000000; + }, + has: jest.fn(), +}; + +export const mockGetCurrentUser = { + user: { + username: 'mockUser', + }, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index eae1ece7e789d..0e73e4bdd6c97 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TIMELINE_EXPORT_URL } from '../../../../../common/constants'; +import { TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL } from '../../../../../common/constants'; import { requestMock } from '../../../detection_engine/routes/__mocks__'; export const getExportTimelinesRequest = () => @@ -16,6 +16,26 @@ export const getExportTimelinesRequest = () => }, }); +export const getImportTimelinesRequest = (filename?: string) => + requestMock.create({ + method: 'post', + path: TIMELINE_IMPORT_URL, + query: { overwrite: false }, + body: { + file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + }, + }); + +export const getImportTimelinesRequestEnableOverwrite = (filename?: string) => + requestMock.create({ + method: 'post', + path: TIMELINE_IMPORT_URL, + query: { overwrite: true }, + body: { + file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + }, + }); + export const mockTimelinesSavedObjects = () => ({ saved_objects: [ { diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 3ded959aced36..b8e7be13fff34 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -21,7 +21,7 @@ import { exportTimelinesQuerySchema, } from './schemas/export_timelines_schema'; -import { getExportTimelineByObjectIds } from './utils'; +import { getExportTimelineByObjectIds } from './utils/export_timelines'; export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { router.post( diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts new file mode 100644 index 0000000000000..e89aef4c70ecb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts @@ -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 { getImportTimelinesRequest } from './__mocks__/request_responses'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../detection_engine/routes/__mocks__'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; + +import { + mockConfig, + mockUniqueParsedObjects, + mockParsedObjects, + mockDuplicateIdErrors, + mockGetCurrentUser, + mockGetTimelineValue, + mockParsedTimelineObject, +} from './__mocks__/import_timelines'; + +describe('import timelines', () => { + let config: jest.Mock; + let server: ReturnType; + let request: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + let mockGetTimeline: jest.Mock; + let mockPersistTimeline: jest.Mock; + let mockPersistPinnedEventOnTimeline: jest.Mock; + let mockPersistNote: jest.Mock; + const newTimelineSavedObjectId = '79deb4c0-6bc1-11ea-9999-f5341fb7a189'; + const newTimelineVersion = '9999'; + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + config = jest.fn().mockImplementation(() => { + return mockConfig; + }); + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + mockGetTimeline = jest.fn(); + mockPersistTimeline = jest.fn(); + mockPersistPinnedEventOnTimeline = jest.fn(); + mockPersistNote = jest.fn(); + + jest.doMock('../create_timelines_stream_from_ndjson', () => { + return { + createTimelinesStreamFromNdJson: jest.fn().mockReturnValue(mockParsedObjects), + }; + }); + + jest.doMock('../../../../../../../../src/legacy/utils', () => { + return { + createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedObjects), + }; + }); + + jest.doMock('./utils/import_timelines', () => { + const originalModule = jest.requireActual('./utils/import_timelines'); + return { + ...originalModule, + getTupleDuplicateErrorsAndUniqueTimeline: jest + .fn() + .mockReturnValue([mockDuplicateIdErrors, mockUniqueParsedObjects]), + }; + }); + }); + + describe('Import a new timeline', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion }, + }), + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, config, securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual(mockUniqueParsedObjects[0].savedObjectId); + }); + + test('should Create a new timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should Create a new timeline savedObject without timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toBeNull(); + }); + + test('should Create a new timeline savedObject witn given timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTimelineObject); + }); + + test('should Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).toHaveBeenCalled(); + }); + + test('should Create a new pinned event without pinnedEventSavedObjectId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new pinned event with pinnedEventId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][2]).toEqual( + mockUniqueParsedObjects[0].pinnedEventIds[0] + ); + }); + + test('should Create a new pinned event with new timelineSavedObjectId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual(newTimelineSavedObjectId); + }); + + test('should Create notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote).toHaveBeenCalled(); + }); + + test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide note content when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(newTimelineVersion); + }); + + test('should provide new notes when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedObjects[0].globalNotes[0].note, + timelineId: newTimelineSavedObjectId, + }); + expect(mockPersistNote.mock.calls[1][3]).toEqual({ + eventId: mockUniqueParsedObjects[0].eventNotes[0].eventId, + note: mockUniqueParsedObjects[0].eventNotes[0].note, + timelineId: newTimelineSavedObjectId, + }); + expect(mockPersistNote.mock.calls[2][3]).toEqual({ + eventId: mockUniqueParsedObjects[0].eventNotes[1].eventId, + note: mockUniqueParsedObjects[0].eventNotes[1].note, + timelineId: newTimelineSavedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + }); + + describe('Import a timeline already exist but overwrite is not allowed', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue), + persistTimeline: mockPersistTimeline, + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, config, securitySetup); + }); + + test('returns error message', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: `timeline_id: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + }, + }, + ], + }); + }); + }); + + describe('request validation', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' }, + }), + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue( + new Error('Test error') + ), + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + }); + test('disallows invalid query', async () => { + request = requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + + importTimelinesRoute(server.router, config, securitySetup); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + 'child "file" fails because ["file" is required]' + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts new file mode 100644 index 0000000000000..fefe31b2f36d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extname } from 'path'; +import { chunk, omit, set } from 'lodash/fp'; +import { + buildRouteValidation, + buildSiemResponse, + createBulkErrorObject, + BulkError, + transformError, +} from '../../detection_engine/routes/utils'; + +import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson'; +import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils'; + +import { + createTimelines, + getTupleDuplicateErrorsAndUniqueTimeline, + isBulkError, + isImportRegular, + ImportTimelineResponse, + ImportTimelinesRequestParams, + ImportTimelinesSchema, + PromiseFromStreams, +} from './utils/import_timelines'; + +import { IRouter } from '../../../../../../../../src/core/server'; +import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; +import { importTimelinesPayloadSchema } from './schemas/import_timelines_schema'; +import { importRulesSchema } from '../../detection_engine/routes/schemas/response/import_rules_schema'; +import { LegacyServices } from '../../../types'; + +import { Timeline } from '../saved_object'; +import { validate } from '../../detection_engine/routes/rules/validate'; +import { FrameworkRequest } from '../../framework'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; + +const CHUNK_PARSED_OBJECT_SIZE = 10; + +const timelineLib = new Timeline(); + +export const importTimelinesRoute = ( + router: IRouter, + config: LegacyServices['config'], + securityPluginSetup: SecurityPluginSetup +) => { + router.post( + { + path: `${TIMELINE_IMPORT_URL}`, + validate: { + body: buildRouteValidation( + importTimelinesPayloadSchema + ), + }, + options: { + tags: ['access:siem'], + body: { + maxBytes: config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + }, + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; + if (!savedObjectsClient) { + return siemResponse.error({ statusCode: 404 }); + } + const { filename } = request.body.file.hapi; + + const fileExtension = extname(filename).toLowerCase(); + + if (fileExtension !== '.ndjson') { + return siemResponse.error({ + statusCode: 400, + body: `Invalid file extension ${fileExtension}`, + }); + } + + const objectLimit = config().get('savedObjects.maxImportExportSize'); + + try { + const readStream = createTimelinesStreamFromNdJson(objectLimit); + const parsedObjects = await createPromiseFromStreams([ + request.body.file, + ...readStream, + ]); + const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueTimeline( + parsedObjects, + false + ); + const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); + let importTimelineResponse: ImportTimelineResponse[] = []; + + const user = await securityPluginSetup.authc.getCurrentUser(request); + let frameworkRequest = set('context.core.savedObjects.client', savedObjectsClient, request); + frameworkRequest = set('user', user, frameworkRequest); + + while (chunkParseObjects.length) { + const batchParseObjects = chunkParseObjects.shift() ?? []; + const newImportTimelineResponse = await Promise.all( + batchParseObjects.reduce>>( + (accum, parsedTimeline) => { + const importsWorkerPromise = new Promise( + async (resolve, reject) => { + if (parsedTimeline instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedTimeline.message, + }) + ); + + return null; + } + const { + savedObjectId, + pinnedEventIds, + globalNotes, + eventNotes, + } = parsedTimeline; + const parsedTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + parsedTimeline + ); + try { + let timeline = null; + try { + timeline = await timelineLib.getTimeline( + (frameworkRequest as unknown) as FrameworkRequest, + savedObjectId + ); + // eslint-disable-next-line no-empty + } catch (e) {} + + if (timeline == null) { + const newSavedObjectId = await createTimelines( + (frameworkRequest as unknown) as FrameworkRequest, + parsedTimelineObject, + null, // timelineSavedObjectId + null, // timelineVersion + pinnedEventIds, + [...globalNotes, ...eventNotes], + [] // existing note ids + ); + + resolve({ timeline_id: newSavedObjectId, status_code: 200 }); + } else { + resolve( + createBulkErrorObject({ + id: savedObjectId, + statusCode: 409, + message: `timeline_id: "${savedObjectId}" already exists`, + }) + ); + } + } catch (err) { + resolve( + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: err.message, + }) + ); + } + } + ); + return [...accum, importsWorkerPromise]; + }, + [] + ) + ); + importTimelineResponse = [ + ...duplicateIdErrors, + ...importTimelineResponse, + ...newImportTimelineResponse, + ]; + } + + const errorsResp = importTimelineResponse.filter(resp => isBulkError(resp)) as BulkError[]; + const successes = importTimelineResponse.filter(resp => { + if (isImportRegular(resp)) { + return resp.status_code === 200; + } else { + return false; + } + }); + const importTimelines: ImportTimelinesSchema = { + success: errorsResp.length === 0, + success_count: successes.length, + errors: errorsResp, + }; + const [validated, errors] = validate(importTimelines, importRulesSchema); + + if (errors != null) { + return siemResponse.error({ statusCode: 500, body: errors }); + } else { + return response.ok({ body: validated ?? {} }); + } + } catch (err) { + const error = transformError(err); + + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts new file mode 100644 index 0000000000000..61ffa9681c53a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Joi from 'joi'; +import { + columns, + created, + createdBy, + dataProviders, + dateRange, + description, + eventNotes, + eventType, + favorite, + filters, + globalNotes, + kqlMode, + kqlQuery, + savedObjectId, + savedQueryId, + sort, + title, + updated, + updatedBy, + version, + pinnedEventIds, +} from './schemas'; + +export const importTimelinesPayloadSchema = Joi.object({ + file: Joi.object().required(), +}); + +export const importTimelinesSchema = Joi.object({ + columns, + created, + createdBy, + dataProviders, + dateRange, + description, + eventNotes, + eventType, + filters, + favorite, + globalNotes, + kqlMode, + kqlQuery, + savedObjectId, + savedQueryId, + sort, + title, + updated, + updatedBy, + version, + pinnedEventIds, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index 67697c347634e..63aee97729141 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -5,9 +5,162 @@ */ import Joi from 'joi'; +const allowEmptyString = Joi.string().allow([null, '']); +const columnHeaderType = Joi.string(); +export const created = Joi.number().allow(null); +export const createdBy = Joi.string(); + +export const description = allowEmptyString; +export const end = Joi.number(); +export const eventId = allowEmptyString; +export const eventType = Joi.string(); + +export const filters = Joi.array() + .items( + Joi.object({ + meta: Joi.object({ + alias: allowEmptyString, + controlledBy: allowEmptyString, + disabled: Joi.boolean().allow(null), + field: allowEmptyString, + formattedValue: allowEmptyString, + index: { + type: 'keyword', + }, + key: { + type: 'keyword', + }, + negate: { + type: 'boolean', + }, + params: allowEmptyString, + type: { + type: 'keyword', + }, + value: allowEmptyString, + }), + exists: allowEmptyString, + match_all: allowEmptyString, + missing: allowEmptyString, + query: allowEmptyString, + range: allowEmptyString, + script: allowEmptyString, + }) + ) + .allow(null); + +const name = allowEmptyString; + +export const noteId = allowEmptyString; +export const note = allowEmptyString; + +export const start = Joi.number(); +export const savedQueryId = allowEmptyString; +export const savedObjectId = allowEmptyString; + +export const timelineId = allowEmptyString; +export const title = allowEmptyString; + +export const updated = Joi.number().allow(null); +export const updatedBy = allowEmptyString; +export const version = allowEmptyString; + +export const columns = Joi.array().items( + Joi.object({ + aggregatable: Joi.boolean().allow(null), + category: Joi.string(), + columnHeaderType, + description, + example: allowEmptyString, + indexes: allowEmptyString, + id: Joi.string(), + name, + placeholder: allowEmptyString, + searchable: Joi.boolean().allow(null), + type: Joi.string(), + }).required() +); +export const dataProviders = Joi.array() + .items( + Joi.object({ + id: Joi.string(), + name: allowEmptyString, + enabled: Joi.boolean().allow(null), + excluded: Joi.boolean().allow(null), + kqlQuery: allowEmptyString, + queryMatch: Joi.object({ + field: allowEmptyString, + displayField: allowEmptyString, + value: allowEmptyString, + displayValue: allowEmptyString, + operator: allowEmptyString, + }), + and: Joi.array() + .items( + Joi.object({ + id: Joi.string(), + name, + enabled: Joi.boolean().allow(null), + excluded: Joi.boolean().allow(null), + kqlQuery: allowEmptyString, + queryMatch: Joi.object({ + field: allowEmptyString, + displayField: allowEmptyString, + value: allowEmptyString, + displayValue: allowEmptyString, + operator: allowEmptyString, + }).allow(null), + }) + ) + .allow(null), + }) + ) + .allow(null); +export const dateRange = Joi.object({ + start, + end, +}); +export const favorite = Joi.array().items( + Joi.object({ + keySearch: Joi.string(), + fullName: Joi.string(), + userName: Joi.string(), + favoriteDate: Joi.number(), + }).allow(null) +); +const noteItem = Joi.object({ + noteId, + version, + eventId, + note, + timelineId, + created, + createdBy, + updated, + updatedBy, +}); +export const eventNotes = Joi.array().items(noteItem); +export const globalNotes = Joi.array().items(noteItem); +export const kqlMode = Joi.string(); +export const kqlQuery = Joi.object({ + filterQuery: Joi.object({ + kuery: Joi.object({ + kind: Joi.string(), + expression: allowEmptyString, + }), + serializedQuery: allowEmptyString, + }), +}); +export const pinnedEventIds = Joi.array() + .items(Joi.string()) + .allow(null); +export const sort = Joi.object({ + columnId: Joi.string(), + sortDirection: Joi.string(), +}); /* eslint-disable @typescript-eslint/camelcase */ export const ids = Joi.array().items(Joi.string()); export const exclude_export_details = Joi.boolean(); -export const file_name = Joi.string(); +export const file_name = allowEmptyString; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts similarity index 85% rename from x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts rename to x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts index 066862e025833..8a28100fbae82 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts @@ -3,37 +3,53 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { set as _set } from 'lodash/fp'; import { + noteSavedObjectType, + pinnedEventSavedObjectType, + timelineSavedObjectType, +} from '../../../../saved_objects'; +import { NoteSavedObject } from '../../../note/types'; +import { PinnedEventSavedObject } from '../../../pinned_event/types'; +import { convertSavedObjectToSavedTimeline } from '../../convert_saved_object_to_savedtimeline'; + +import { convertSavedObjectToSavedPinnedEvent } from '../../../pinned_event/saved_object'; +import { convertSavedObjectToSavedNote } from '../../../note/saved_object'; + +import { + SavedObjectsClient, SavedObjectsFindOptions, SavedObjectsFindResponse, -} from '../../../../../../../../src/core/server'; +} from '../../../../../../../../../src/core/server'; import { + ExportedTimelines, ExportTimelineSavedObjectsClient, ExportTimelineRequest, ExportedNotes, TimelineSavedObject, - ExportedTimelines, -} from '../types'; -import { - timelineSavedObjectType, - noteSavedObjectType, - pinnedEventSavedObjectType, -} from '../../../saved_objects'; - -import { convertSavedObjectToSavedNote } from '../../note/saved_object'; -import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; -import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; -import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; -import { NoteSavedObject } from '../../note/types'; -import { PinnedEventSavedObject } from '../../pinned_event/types'; +} from '../../types'; + +import { transformDataToNdjson } from '../../../detection_engine/routes/rules/utils'; +export type TimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; const getAllSavedPinnedEvents = ( pinnedEventsSavedObjects: SavedObjectsFindResponse ): PinnedEventSavedObject[] => { return pinnedEventsSavedObjects != null - ? pinnedEventsSavedObjects.saved_objects.map(savedObject => + ? (pinnedEventsSavedObjects?.saved_objects ?? []).map(savedObject => convertSavedObjectToSavedPinnedEvent(savedObject) ) : []; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts new file mode 100644 index 0000000000000..5596d0c70f5ea --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { has } from 'lodash/fp'; +import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils'; +import { PinnedEvent } from '../../../pinned_event/saved_object'; +import { Note } from '../../../note/saved_object'; + +import { Timeline } from '../../saved_object'; +import { SavedTimeline } from '../../types'; +import { FrameworkRequest } from '../../../framework'; +import { SavedNote } from '../../../note/types'; +import { NoteResult } from '../../../../graphql/types'; +import { HapiReadableStream } from '../../../detection_engine/rules/types'; + +const pinnedEventLib = new PinnedEvent(); +const timelineLib = new Timeline(); +const noteLib = new Note(); + +export interface ImportTimelinesSchema { + success: boolean; + success_count: number; + errors: BulkError[]; +} + +export type ImportedTimeline = SavedTimeline & { + savedObjectId: string; + pinnedEventIds: string[]; + globalNotes: NoteResult[]; + eventNotes: NoteResult[]; +}; + +interface ImportRegular { + timeline_id: string; + status_code: number; + message?: string; +} + +export type ImportTimelineResponse = ImportRegular | BulkError; +export type PromiseFromStreams = ImportedTimeline; +export interface ImportTimelinesRequestParams { + body: { file: HapiReadableStream }; +} + +export const getTupleDuplicateErrorsAndUniqueTimeline = ( + timelines: PromiseFromStreams[], + isOverwrite: boolean +): [BulkError[], PromiseFromStreams[]] => { + const { errors, timelinesAcc } = timelines.reduce( + (acc, parsedTimeline) => { + if (parsedTimeline instanceof Error) { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } else { + const { savedObjectId } = parsedTimeline; + if (savedObjectId != null) { + if (acc.timelinesAcc.has(savedObjectId) && !isOverwrite) { + acc.errors.set( + uuid.v4(), + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: `More than one timeline with savedObjectId: "${savedObjectId}" found`, + }) + ); + } + acc.timelinesAcc.set(savedObjectId, parsedTimeline); + } else { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + timelinesAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(timelinesAcc.values())]; +}; + +export const saveTimelines = async ( + frameworkRequest: FrameworkRequest, + timeline: SavedTimeline, + timelineSavedObjectId?: string | null, + timelineVersion?: string | null +) => { + const newTimelineRes = await timelineLib.persistTimeline( + frameworkRequest, + timelineSavedObjectId ?? null, + timelineVersion ?? null, + timeline + ); + + return { + newTimelineSavedObjectId: newTimelineRes?.timeline?.savedObjectId ?? null, + newTimelineVersion: newTimelineRes?.timeline?.version ?? null, + }; +}; + +export const savePinnedEvents = ( + frameworkRequest: FrameworkRequest, + timelineSavedObjectId: string, + pinnedEventIds?: string[] | null +) => { + return ( + pinnedEventIds?.map(eventId => { + return pinnedEventLib.persistPinnedEventOnTimeline( + frameworkRequest, + null, // pinnedEventSavedObjectId + eventId, + timelineSavedObjectId + ); + }) ?? [] + ); +}; + +export const saveNotes = ( + frameworkRequest: FrameworkRequest, + timelineSavedObjectId: string, + timelineVersion?: string | null, + existingNoteIds?: string[], + newNotes?: NoteResult[] +) => { + return ( + newNotes?.map(note => { + const newNote: SavedNote = { + eventId: note.eventId, + note: note.note, + timelineId: timelineSavedObjectId, + }; + + return noteLib.persistNote( + frameworkRequest, + existingNoteIds?.find(nId => nId === note.noteId) ?? null, + timelineVersion ?? null, + newNote + ); + }) ?? [] + ); +}; + +export const createTimelines = async ( + frameworkRequest: FrameworkRequest, + timeline: SavedTimeline, + timelineSavedObjectId?: string | null, + timelineVersion?: string | null, + pinnedEventIds?: string[] | null, + notes?: NoteResult[], + existingNoteIds?: string[] +) => { + const { newTimelineSavedObjectId, newTimelineVersion } = await saveTimelines( + frameworkRequest, + timeline, + timelineSavedObjectId, + timelineVersion + ); + await Promise.all([ + savePinnedEvents( + frameworkRequest, + timelineSavedObjectId ?? newTimelineSavedObjectId, + pinnedEventIds + ), + saveNotes( + frameworkRequest, + timelineSavedObjectId ?? newTimelineSavedObjectId, + newTimelineVersion, + existingNoteIds, + notes + ), + ]); + + return newTimelineSavedObjectId; +}; + +export const isImportRegular = ( + importTimelineResponse: ImportTimelineResponse +): importTimelineResponse is ImportRegular => { + return !has('error', importTimelineResponse) && has('status_code', importTimelineResponse); +}; + +export const isBulkError = ( + importRuleResponse: ImportTimelineResponse +): importRuleResponse is BulkError => { + return has('error', importRuleResponse); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index 88d7fcdb68164..bc6975331ad9b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -138,19 +138,19 @@ export class Timeline { timeline: SavedTimeline ): Promise { const savedObjectsClient = request.context.core.savedObjects.client; - try { if (timelineId == null) { // Create new timeline + const newTimeline = convertSavedObjectToSavedTimeline( + await savedObjectsClient.create( + timelineSavedObjectType, + pickSavedTimeline(timelineId, timeline, request.user) + ) + ); return { code: 200, message: 'success', - timeline: convertSavedObjectToSavedTimeline( - await savedObjectsClient.create( - timelineSavedObjectType, - pickSavedTimeline(timelineId, timeline, request.user) - ) - ), + timeline: newTimeline, }; } // Update Timeline @@ -162,6 +162,7 @@ export class Timeline { version: version || undefined, } ); + return { code: 200, message: 'success', diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts index 716eea3f8df5b..10929c3d03641 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts @@ -66,10 +66,9 @@ export const formatTlsEdges = (buckets: TlsBuckets[]): TlsEdges[] => { const edge: TlsEdges = { node: { _id: bucket.key, - alternativeNames: bucket.alternative_names.buckets.map(({ key }) => key), - commonNames: bucket.common_names.buckets.map(({ key }) => key), + subjects: bucket.subjects.buckets.map(({ key }) => key), ja3: bucket.ja3.buckets.map(({ key }) => key), - issuerNames: bucket.issuer_names.buckets.map(({ key }) => key), + issuers: bucket.issuers.buckets.map(({ key }) => key), // eslint-disable-next-line @typescript-eslint/camelcase notAfter: bucket.not_after.buckets.map(({ key_as_string }) => key_as_string), }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts index 4b27d541ec992..b97a6fa509ef2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts @@ -20,11 +20,10 @@ export const mockTlsQuery = { order: { _key: 'desc' }, }, aggs: { - issuer_names: { terms: { field: 'tls.server_certificate.issuer.common_name' } }, - common_names: { terms: { field: 'tls.server_certificate.subject.common_name' } }, - alternative_names: { terms: { field: 'tls.server_certificate.alternative_names' } }, - not_after: { terms: { field: 'tls.server_certificate.not_after' } }, - ja3: { terms: { field: 'tls.fingerprints.ja3.hash' } }, + issuers: { terms: { field: 'tls.server.issuer' } }, + subjects: { terms: { field: 'tls.server.subject' } }, + not_after: { terms: { field: 'tls.server.not_after' } }, + ja3: { terms: { field: 'tls.server.ja3s' } }, }, }, }, @@ -44,16 +43,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fff8dc95436e0e25ce46b1526a1a547e8cf3bb82', - alternativeNames: [ - '*.1.nflxso.net', - '*.a.nflxso.net', - 'assets.nflxext.com', - 'cast.netflix.com', - 'codex.nflxext.com', - 'tvui.netflix.com', - ], - commonNames: ['*.1.nflxso.net'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + subjects: ['*.1.nflxso.net'], + issuers: ['DigiCert SHA2 Secure Server CA'], ja3: ['95d2dd53a89b334cddd5c22e81e7fe61'], notAfter: ['2019-10-27T12:00:00.000Z'], }, @@ -65,9 +56,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fd8440c4b20978b173e0910e2639d114f0d405c5', - alternativeNames: ['*.cogocast.net', 'cogocast.net'], - commonNames: ['cogocast.net'], - issuerNames: ['Amazon'], + subjects: ['cogocast.net'], + issuers: ['Amazon'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2020-02-01T12:00:00.000Z'], }, @@ -76,12 +66,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd' }, node: { _id: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd', - alternativeNames: [ - 'player-devintever2-imperva.mountain.siriusxm.com', - 'player-devintever2.mountain.siriusxm.com', - ], - commonNames: ['player-devintever2.mountain.siriusxm.com'], - issuerNames: ['Trustwave Organization Validation SHA256 CA, Level 1'], + subjects: ['player-devintever2.mountain.siriusxm.com'], + issuers: ['Trustwave Organization Validation SHA256 CA, Level 1'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-03-06T21:57:09.000Z'], }, @@ -90,15 +76,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fccf375789cb7e671502a7b0cc969f218a4b2c70' }, node: { _id: 'fccf375789cb7e671502a7b0cc969f218a4b2c70', - alternativeNames: [ - 'appleid-nc-s.apple.com', - 'appleid-nwk-s.apple.com', - 'appleid-prn-s.apple.com', - 'appleid-rno-s.apple.com', - 'appleid.apple.com', - ], - commonNames: ['appleid.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['appleid.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-07-04T12:00:00.000Z'], }, @@ -107,20 +86,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981' }, node: { _id: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981', - alternativeNames: [ - 'api.itunes.apple.com', - 'appsto.re', - 'ax.init.itunes.apple.com', - 'bag.itunes.apple.com', - 'bookkeeper.itunes.apple.com', - 'c.itunes.apple.com', - 'carrierbundle.itunes.apple.com', - 'client-api.itunes.apple.com', - 'cma.itunes.apple.com', - 'courses.apple.com', - ], - commonNames: ['itunes.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['itunes.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['a441a33aaee795f498d6b764cc78989a'], notAfter: ['2020-03-24T12:00:00.000Z'], }, @@ -129,20 +96,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e' }, node: { _id: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e', - alternativeNames: [ - '*.adlercasino.com', - '*.allaustraliancasino.com', - '*.alletf.com', - '*.appareldesignpartners.com', - '*.atmosfir.net', - '*.cityofboston.gov', - '*.cp.mytoyotaentune.com', - '*.decathlon.be', - '*.decathlon.co.uk', - '*.decathlon.de', - ], - commonNames: ['incapsula.com'], - issuerNames: ['GlobalSign CloudSSL CA - SHA256 - G3'], + subjects: ['incapsula.com'], + issuers: ['GlobalSign CloudSSL CA - SHA256 - G3'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-04T14:05:06.000Z'], }, @@ -151,9 +106,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb70d78ffa663a3a4374d841b3288d2de9759566' }, node: { _id: 'fb70d78ffa663a3a4374d841b3288d2de9759566', - alternativeNames: ['*.siriusxm.com', 'siriusxm.com'], - commonNames: ['*.siriusxm.com'], - issuerNames: ['DigiCert Baltimore CA-2 G2'], + subjects: ['*.siriusxm.com'], + issuers: ['DigiCert Baltimore CA-2 G2'], ja3: ['535aca3d99fc247509cd50933cd71d37', '6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-10-27T12:00:00.000Z'], }, @@ -162,16 +116,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0' }, node: { _id: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0', - alternativeNames: [ - 'photos.amazon.co.uk', - 'photos.amazon.de', - 'photos.amazon.es', - 'photos.amazon.eu', - 'photos.amazon.fr', - 'photos.amazon.it', - ], - commonNames: ['photos.amazon.eu'], - issuerNames: ['Amazon'], + subjects: ['photos.amazon.eu'], + issuers: ['Amazon'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-23T12:00:00.000Z'], }, @@ -180,20 +126,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f9815293c883a6006f0b2d95a4895bdc501fd174' }, node: { _id: 'f9815293c883a6006f0b2d95a4895bdc501fd174', - alternativeNames: [ - '*.api.cdn.hbo.com', - '*.artist.cdn.hbo.com', - '*.cdn.hbo.com', - '*.lv3.cdn.hbo.com', - 'artist.api.cdn.hbo.com', - 'artist.api.lv3.cdn.hbo.com', - 'artist.staging.cdn.hbo.com', - 'artist.staging.hurley.lv3.cdn.hbo.com', - 'atv.api.lv3.cdn.hbo.com', - 'atv.staging.hurley.lv3.cdn.hbo.com', - ], - commonNames: ['cdn.hbo.com'], - issuerNames: ['Sectigo RSA Organization Validation Secure Server CA'], + subjects: ['cdn.hbo.com'], + issuers: ['Sectigo RSA Organization Validation Secure Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-02-10T23:59:59.000Z'], }, @@ -202,9 +136,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f8db6a69797e383dca2529727369595733123386' }, node: { _id: 'f8db6a69797e383dca2529727369595733123386', - alternativeNames: ['www.google.com'], - commonNames: ['www.google.com'], - issuerNames: ['GTS CA 1O1'], + subjects: ['www.google.com'], + issuers: ['GTS CA 1O1'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2019-12-10T13:32:54.000Z'], }, @@ -226,7 +159,7 @@ export const mockRequest = { timerange: { interval: '12h', from: 1570716261267, to: 1570802661267 }, }, query: - 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n alternativeNames\n commonNames\n ja3\n issuerNames\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', + 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n subjects\n ja3\n issuers\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', }, }; @@ -250,28 +183,16 @@ export const mockResponse = { { key: 1572177600000, key_as_string: '2019-10-27T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Secure Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.1.nflxso.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.1.nflxso.net', doc_count: 1 }, - { key: '*.a.nflxso.net', doc_count: 1 }, - { key: 'assets.nflxext.com', doc_count: 1 }, - { key: 'cast.netflix.com', doc_count: 1 }, - { key: 'codex.nflxext.com', doc_count: 1 }, - { key: 'tvui.netflix.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -288,24 +209,16 @@ export const mockResponse = { { key: 1580558400000, key_as_string: '2020-02-01T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cogocast.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.cogocast.net', doc_count: 1 }, - { key: 'cogocast.net', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -322,26 +235,18 @@ export const mockResponse = { { key: 1583531829000, key_as_string: '2020-03-06T21:57:09.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Trustwave Organization Validation SHA256 CA, Level 1', doc_count: 1 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'player-devintever2-imperva.mountain.siriusxm.com', doc_count: 1 }, - { key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -358,27 +263,16 @@ export const mockResponse = { { key: 1593864000000, key_as_string: '2020-07-04T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'appleid.apple.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'appleid-nc-s.apple.com', doc_count: 1 }, - { key: 'appleid-nwk-s.apple.com', doc_count: 1 }, - { key: 'appleid-prn-s.apple.com', doc_count: 1 }, - { key: 'appleid-rno-s.apple.com', doc_count: 1 }, - { key: 'appleid.apple.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -395,32 +289,16 @@ export const mockResponse = { { key: 1585051200000, key_as_string: '2020-03-24T12:00:00.000Z', doc_count: 2 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 2 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'itunes.apple.com', doc_count: 2 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 156, - buckets: [ - { key: 'api.itunes.apple.com', doc_count: 2 }, - { key: 'appsto.re', doc_count: 2 }, - { key: 'ax.init.itunes.apple.com', doc_count: 2 }, - { key: 'bag.itunes.apple.com', doc_count: 2 }, - { key: 'bookkeeper.itunes.apple.com', doc_count: 2 }, - { key: 'c.itunes.apple.com', doc_count: 2 }, - { key: 'carrierbundle.itunes.apple.com', doc_count: 2 }, - { key: 'client-api.itunes.apple.com', doc_count: 2 }, - { key: 'cma.itunes.apple.com', doc_count: 2 }, - { key: 'courses.apple.com', doc_count: 2 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -437,32 +315,16 @@ export const mockResponse = { { key: 1586009106000, key_as_string: '2020-04-04T14:05:06.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GlobalSign CloudSSL CA - SHA256 - G3', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'incapsula.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 110, - buckets: [ - { key: '*.adlercasino.com', doc_count: 1 }, - { key: '*.allaustraliancasino.com', doc_count: 1 }, - { key: '*.alletf.com', doc_count: 1 }, - { key: '*.appareldesignpartners.com', doc_count: 1 }, - { key: '*.atmosfir.net', doc_count: 1 }, - { key: '*.cityofboston.gov', doc_count: 1 }, - { key: '*.cp.mytoyotaentune.com', doc_count: 1 }, - { key: '*.decathlon.be', doc_count: 1 }, - { key: '*.decathlon.co.uk', doc_count: 1 }, - { key: '*.decathlon.de', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -479,24 +341,16 @@ export const mockResponse = { { key: 1635336000000, key_as_string: '2021-10-27T12:00:00.000Z', doc_count: 325 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert Baltimore CA-2 G2', doc_count: 325 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.siriusxm.com', doc_count: 325 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.siriusxm.com', doc_count: 325 }, - { key: 'siriusxm.com', doc_count: 325 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -516,28 +370,16 @@ export const mockResponse = { { key: 1587643200000, key_as_string: '2020-04-23T12:00:00.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 5 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'photos.amazon.eu', doc_count: 5 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'photos.amazon.co.uk', doc_count: 5 }, - { key: 'photos.amazon.de', doc_count: 5 }, - { key: 'photos.amazon.es', doc_count: 5 }, - { key: 'photos.amazon.eu', doc_count: 5 }, - { key: 'photos.amazon.fr', doc_count: 5 }, - { key: 'photos.amazon.it', doc_count: 5 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -554,34 +396,18 @@ export const mockResponse = { { key: 1613001599000, key_as_string: '2021-02-10T23:59:59.000Z', doc_count: 29 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Sectigo RSA Organization Validation Secure Server CA', doc_count: 29 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cdn.hbo.com', doc_count: 29 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 29, - buckets: [ - { key: '*.api.cdn.hbo.com', doc_count: 29 }, - { key: '*.artist.cdn.hbo.com', doc_count: 29 }, - { key: '*.cdn.hbo.com', doc_count: 29 }, - { key: '*.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -598,17 +424,12 @@ export const mockResponse = { { key: 1575984774000, key_as_string: '2019-12-10T13:32:54.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GTS CA 1O1', doc_count: 5 }], }, - common_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'www.google.com', doc_count: 5 }], - }, - alternative_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'www.google.com', doc_count: 5 }], @@ -643,10 +464,9 @@ export const mockOptions = { fields: [ 'totalCount', '_id', - 'alternativeNames', - 'commonNames', + 'subjects', 'ja3', - 'issuerNames', + 'issuers', 'notAfter', 'edges.cursor.value', 'pageInfo.activePage', diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts index 2ff33a800fcd5..bc65be642dabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts @@ -12,41 +12,36 @@ import { TlsSortField, Direction, TlsFields } from '../../graphql/types'; const getAggs = (querySize: number, sort: TlsSortField) => ({ count: { cardinality: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', }, }, sha1: { terms: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', size: querySize, order: { ...getQueryOrder(sort), }, }, aggs: { - issuer_names: { + issuers: { terms: { - field: 'tls.server_certificate.issuer.common_name', + field: 'tls.server.issuer', }, }, - common_names: { + subjects: { terms: { - field: 'tls.server_certificate.subject.common_name', - }, - }, - alternative_names: { - terms: { - field: 'tls.server_certificate.alternative_names', + field: 'tls.server.subject', }, }, not_after: { terms: { - field: 'tls.server_certificate.not_after', + field: 'tls.server.not_after', }, }, ja3: { terms: { - field: 'tls.fingerprints.ja3.hash', + field: 'tls.server.ja3s', }, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts index bac5426f72e08..1fbb31ba3e0f3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts @@ -18,11 +18,7 @@ export interface TlsBuckets { value_as_string: string; }; - alternative_names: { - buckets: Readonly>; - }; - - common_names: { + subjects: { buckets: Readonly>; }; @@ -30,7 +26,7 @@ export interface TlsBuckets { buckets: Readonly>; }; - issuer_names: { + issuers: { buckets: Readonly>; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts index dc38824989da3..24cae53d5d353 100644 --- a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts @@ -191,6 +191,22 @@ export const buildQuery = ({ ], }, }, + { + bool: { + filter: [ + { + term: { + 'event.category': 'process', + }, + }, + { + term: { + 'event.type': 'start', + }, + }, + ], + }, + }, ], minimum_should_match: 1, filter, diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index c505edc79bc76..2bce9b6a7e1aa 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -21,12 +21,15 @@ import { PluginSetupContract as FeaturesSetup } from '../../../../plugins/featur import { EncryptedSavedObjectsPluginSetup as EncryptedSavedObjectsSetup } from '../../../../plugins/encrypted_saved_objects/server'; import { SpacesPluginSetup as SpacesSetup } from '../../../../plugins/spaces/server'; import { PluginStartContract as ActionsStart } from '../../../../plugins/actions/server'; +import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; import { LegacyServices } from './types'; import { initServer } from './init_server'; import { compose } from './lib/compose/kibana'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; +import { rulesNotificationAlertType } from './lib/detection_engine/notifications/rules_notification_alert_type'; +import { isNotificationAlertExecutor } from './lib/detection_engine/notifications/types'; import { noteSavedObjectType, pinnedEventSavedObjectType, @@ -39,11 +42,12 @@ import { hasListsFeature, listsEnvFeatureFlagName } from './lib/detection_engine export { CoreSetup, CoreStart }; export interface SetupPlugins { + alerting: AlertingSetup; encryptedSavedObjects: EncryptedSavedObjectsSetup; features: FeaturesSetup; + licensing: LicensingPluginSetup; security: SecuritySetup; spaces?: SpacesSetup; - alerting: AlertingSetup; } export interface StartPlugins { @@ -87,7 +91,8 @@ export class Plugin { initRoutes( router, __legacy.config, - plugins.encryptedSavedObjects?.usingEphemeralEncryptionKey ?? false + plugins.encryptedSavedObjects?.usingEphemeralEncryptionKey ?? false, + plugins.security ); plugins.features.registerFeature({ @@ -95,12 +100,15 @@ export class Plugin { name: i18n.translate('xpack.siem.featureRegistry.linkSiemTitle', { defaultMessage: 'SIEM', }), + order: 1100, icon: 'securityAnalyticsApp', navLinkId: 'siem', app: ['siem', 'kibana'], catalogue: ['siem'], privileges: { all: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: [ @@ -126,6 +134,8 @@ export class Plugin { ], }, read: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: ['alert', 'action', 'action_task_params'], @@ -151,12 +161,20 @@ export class Plugin { }); if (plugins.alerting != null) { - const type = signalRulesAlertType({ + const signalRuleType = signalRulesAlertType({ logger: this.logger, version: this.context.env.packageInfo.version, }); - if (isAlertExecutor(type)) { - plugins.alerting.registerType(type); + const ruleNotificationType = rulesNotificationAlertType({ + logger: this.logger, + }); + + if (isAlertExecutor(signalRuleType)) { + plugins.alerting.registerType(signalRuleType); + } + + if (isNotificationAlertExecutor(ruleNotificationType)) { + plugins.alerting.registerType(ruleNotificationType); } } diff --git a/x-pack/legacy/plugins/siem/server/routes/index.ts b/x-pack/legacy/plugins/siem/server/routes/index.ts index 08ff9208ce20b..29c21ad157235 100644 --- a/x-pack/legacy/plugins/siem/server/routes/index.ts +++ b/x-pack/legacy/plugins/siem/server/routes/index.ts @@ -29,12 +29,15 @@ import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_ru import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { importTimelinesRoute } from '../lib/timeline/routes/import_timelines_route'; import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route'; +import { SecurityPluginSetup } from '../../../../../plugins/security/server/'; export const initRoutes = ( router: IRouter, config: LegacyServices['config'], - usingEphemeralEncryptionKey: boolean + usingEphemeralEncryptionKey: boolean, + security: SecurityPluginSetup ) => { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... @@ -55,6 +58,7 @@ export const initRoutes = ( importRulesRoute(router, config); exportRulesRoute(router, config); + importTimelinesRoute(router, config, security); exportTimelinesRoute(router, config); findRulesStatusesRoute(router); diff --git a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts index 61197d6dc373d..7fafe6584d831 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts @@ -16,4 +16,11 @@ export enum API_URLS { PING_HISTOGRAM = `/api/uptime/ping/histogram`, SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, FILTERS = `/api/uptime/filters`, + logPageView = `/api/uptime/logPageView`, + + ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`, + ML_SETUP_MODULE = '/api/ml/modules/setup/', + ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, + ML_CAPABILITIES = '/api/ml/ml_capabilities', + ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, } diff --git a/x-pack/legacy/plugins/uptime/common/constants/ui.ts b/x-pack/legacy/plugins/uptime/common/constants/ui.ts index 8d223dbbba556..29e8dabf53f92 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/ui.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/ui.ts @@ -15,6 +15,10 @@ export enum STATUS { DOWN = 'down', } +export const ML_JOB_ID = 'high_latency_by_geo'; + +export const ML_MODULE_ID = 'uptime_heartbeat'; + export const UNNAMED_LOCATION = 'Unnamed-location'; export const SHORT_TS_LOCALE = 'en-short-locale'; diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index 1a37ce0b18c73..bd017e6cfaf4c 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -552,6 +552,8 @@ export interface GetMonitorStatesQueryArgs { filters?: string | null; statusFilter?: string | null; + + pageSize: number; } // ==================================================== diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx index 8d2b8d2cd8e0d..7d1cb08cb8b1c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx @@ -7,10 +7,21 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useUrlParams } from '../../../hooks'; -import { getMonitorDurationAction } from '../../../state/actions'; +import { + getAnomalyRecordsAction, + getMLCapabilitiesAction, + getMonitorDurationAction, +} from '../../../state/actions'; import { DurationChartComponent } from '../../functional/charts'; -import { selectDurationLines } from '../../../state/selectors'; +import { + anomaliesSelector, + hasMLFeatureAvailable, + hasMLJobSelector, + selectDurationLines, +} from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; +import { getMLJobId } from '../../../state/api/ml_anomaly'; +import { JobStat } from '../../../../../../../plugins/ml/common/types/data_recognizer'; interface Props { monitorId: string; @@ -18,24 +29,58 @@ interface Props { export const DurationChart: React.FC = ({ monitorId }: Props) => { const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd } = getUrlParams(); + const { + dateRangeStart, + dateRangeEnd, + absoluteDateRangeStart, + absoluteDateRangeEnd, + } = getUrlParams(); - const { monitor_duration, loading } = useSelector(selectDurationLines); + const { durationLines, loading } = useSelector(selectDurationLines); + + const isMLAvailable = useSelector(hasMLFeatureAvailable); + + const { data: mlJobs, loading: jobsLoading } = useSelector(hasMLJobSelector); + + const hasMLJob = + !!mlJobs?.jobsExist && + !!mlJobs.jobs.find((job: JobStat) => job.id === getMLJobId(monitorId as string)); + + const anomalies = useSelector(anomaliesSelector); const dispatch = useDispatch(); const { lastRefresh } = useContext(UptimeRefreshContext); useEffect(() => { - dispatch( - getMonitorDurationAction({ monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd }) - ); + if (isMLAvailable) { + const anomalyParams = { + listOfMonitorIds: [monitorId], + dateStart: absoluteDateRangeStart, + dateEnd: absoluteDateRangeEnd, + }; + + dispatch(getAnomalyRecordsAction.get(anomalyParams)); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId, isMLAvailable]); + + useEffect(() => { + const params = { monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd }; + dispatch(getMonitorDurationAction(params)); }, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId]); + useEffect(() => { + dispatch(getMLCapabilitiesAction.get()); + }, [dispatch]); + return ( ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx index cac7042ca5b5c..b383a696095a3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx @@ -11,7 +11,7 @@ import { indexStatusSelector } from '../../../state/selectors'; import { EmptyStateComponent } from '../../functional/empty_state/empty_state'; export const EmptyState: React.FC = ({ children }) => { - const { data, loading, errors } = useSelector(indexStatusSelector); + const { data, loading, error } = useSelector(indexStatusSelector); const dispatch = useDispatch(); @@ -23,7 +23,7 @@ export const EmptyState: React.FC = ({ children }) => { ); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx index 456fa2b30bca8..9e7834ae6f242 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx @@ -22,7 +22,8 @@ interface StateProps { } interface DispatchProps { - loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => void; + loadMonitorStatus: typeof getMonitorStatusAction; + loadSelectedMonitor: typeof getSelectedMonitorAction; } interface OwnProps { @@ -33,6 +34,7 @@ type Props = OwnProps & StateProps & DispatchProps; const Container: React.FC = ({ loadMonitorStatus, + loadSelectedMonitor, monitorId, monitorStatus, monitorLocations, @@ -43,8 +45,9 @@ const Container: React.FC = ({ const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams(); useEffect(() => { - loadMonitorStatus(dateStart, dateEnd, monitorId); - }, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh]); + loadMonitorStatus({ dateStart, dateEnd, monitorId }); + loadSelectedMonitor({ monitorId }); + }, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh, loadSelectedMonitor]); return ( ({ }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => { - dispatch( - getMonitorStatusAction({ - monitorId, - dateStart, - dateEnd, - }) - ); - dispatch( - getSelectedMonitorAction({ - monitorId, - }) - ); - }, + loadSelectedMonitor: params => dispatch(getSelectedMonitorAction(params)), + loadMonitorStatus: params => dispatch(getMonitorStatusAction(params)), }); // @ts-ignore TODO: Investigate typescript issues here diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx index a174a7d9c0ea4..09e6dc72b7f98 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/uptime_alerts_context_provider.tsx @@ -17,6 +17,7 @@ export const UptimeAlertsContextProvider: React.FC = ({ children }) => { notifications, triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, uiSettings, + docLinks, }, } = useKibana(); @@ -26,6 +27,7 @@ export const UptimeAlertsContextProvider: React.FC = ({ children }) => { actionTypeRegistry, alertTypeRegistry, charts, + docLinks, dataFieldsFormats: fieldFormats, http, toastNotifications: notifications?.toasts, diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap index 1e2d2b9144416..6c38f3e338cfd 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap @@ -52,6 +52,8 @@ exports[`MonitorCharts component renders the component without errors 1`] = ` } > { it('renders the component without errors', () => { const component = shallowWithRouter( ); expect(component).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx new file mode 100644 index 0000000000000..ad2a6d02c5364 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const Header = styled.div` + font-weight: bold; + padding-left: 4px; +`; + +const RecordSeverity = styled.div` + font-weight: bold; + border-left: 4px solid ${props => props.color}; + padding-left: 2px; +`; + +const TimeDiv = styled.div` + font-weight: 500; + border-bottom: 1px solid gray; + padding-bottom: 2px; +`; + +export const AnnotationTooltip = ({ details }: { details: string }) => { + const data = JSON.parse(details); + + function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + return ( + <> + {moment(data.time).format('lll')} +
+ +
+ + + + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index 6bd4e7431f97a..d149e7a6deb5a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; +import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; +import { SeriesIdentifier } from '@elastic/charts/dist/chart_types/xy_chart/utils/series'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/types'; import { DurationLineSeriesList } from './duration_line_series_list'; @@ -17,6 +18,9 @@ import { ChartWrapper } from './chart_wrapper'; import { useUrlParams } from '../../../hooks'; import { getTickFormat } from './get_tick_format'; import { ChartEmptyState } from './chart_empty_state'; +import { DurationAnomaliesBar } from './duration_line_bar_list'; +import { MLIntegrationComponent } from '../../monitor_details/ml/ml_integeration'; +import { AnomalyRecords } from '../../../state/actions'; interface DurationChartProps { /** @@ -29,6 +33,10 @@ interface DurationChartProps { * To represent the loading spinner on chart */ loading: boolean; + + hasMLJob: boolean; + + anomalies: AnomalyRecords | null; } /** @@ -37,29 +45,64 @@ interface DurationChartProps { * milliseconds. * @param props The props required for this component to render properly */ -export const DurationChartComponent = ({ locationDurationLines, loading }: DurationChartProps) => { +export const DurationChartComponent = ({ + locationDurationLines, + anomalies, + loading, + hasMLJob, +}: DurationChartProps) => { const hasLines = locationDurationLines.length > 0; const [getUrlParams, updateUrlParams] = useUrlParams(); const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams(); + const [hiddenLegends, setHiddenLegends] = useState([]); + const onBrushEnd = (minX: number, maxX: number) => { updateUrlParams({ dateRangeStart: moment(minX).toISOString(), dateRangeEnd: moment(maxX).toISOString(), }); }; + + const legendToggleVisibility = (legendItem: SeriesIdentifier | null) => { + if (legendItem) { + setHiddenLegends(prevState => { + if (prevState.includes(legendItem.specId)) { + return [...prevState.filter(item => item !== legendItem.specId)]; + } else { + return [...prevState, legendItem.specId]; + } + }); + } + }; + return ( <> - -

- -

-
+ + + +

+ {hasMLJob ? ( + + ) : ( + + )} +

+
+
+ + + +
+ {hasLines ? ( @@ -69,6 +112,7 @@ export const DurationChartComponent = ({ locationDurationLines, loading }: Durat showLegendExtra legendPosition={Position.Bottom} onBrushEnd={onBrushEnd} + onLegendItemClick={legendToggleVisibility} /> + ) : ( { + const anomalyAnnotations: Map = new Map(); + + Object.keys(ANOMALY_SEVERITY).forEach(severityLevel => { + anomalyAnnotations.set(severityLevel.toLowerCase(), { rect: [], color: '' }); + }); + + if (anomalies?.anomalies) { + const records = anomalies.anomalies; + records.forEach((record: any) => { + let recordObsvLoc = record.source['observer.geo.name']?.[0] ?? 'N/A'; + if (recordObsvLoc === '') { + recordObsvLoc = 'N/A'; + } + if (hiddenLegends.length && hiddenLegends.includes(`loc-avg-${recordObsvLoc}`)) { + return; + } + const severityLevel = getSeverityType(record.severity); + + const tooltipData = { + time: record.source.timestamp, + score: record.severity, + severity: severityLevel, + color: getSeverityColor(record.severity), + }; + + const anomalyRect = { + coordinates: { + x0: moment(record.source.timestamp).valueOf(), + x1: moment(record.source.timestamp) + .add(record.source.bucket_span, 's') + .valueOf(), + }, + details: JSON.stringify(tooltipData), + }; + anomalyAnnotations.get(severityLevel)!.rect.push(anomalyRect); + anomalyAnnotations.get(severityLevel)!.color = getSeverityColor(record.severity); + }); + } + + const getRectStyle = (color: string) => { + return { + fill: color, + opacity: 1, + strokeWidth: 2, + stroke: color, + }; + }; + + const tooltipFormatter: AnnotationTooltipFormatter = (details?: string) => { + return ; + }; + + return ( + <> + {Array.from(anomalyAnnotations).map(([keyIndex, rectAnnotation]) => { + return rectAnnotation.rect.length > 0 ? ( + + ) : null; + })} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap index 5548189175c55..2d45bbd18a60c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap @@ -749,17 +749,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` @@ -904,7 +884,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` body={

- An error occurred + There was an error fetching your data.

} @@ -971,9 +951,9 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` className="euiText euiText--medium" >

- An error occurred + There was an error fetching your data.

diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx index 20113df3010f8..a74ad543c3318 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx @@ -7,8 +7,9 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { EmptyStateComponent } from '../empty_state'; -import { GraphQLError } from 'graphql'; import { StatesIndexStatus } from '../../../../../common/runtime_types'; +import { IHttpFetchError } from '../../../../../../../../../target/types/core/public/http'; +import { HttpFetchError } from '../../../../../../../../../src/core/public/http/http_fetch_error'; describe('EmptyState component', () => { let statesIndexStatus: StatesIndexStatus; @@ -41,18 +42,8 @@ describe('EmptyState component', () => { }); it(`renders error message when an error occurs`, () => { - const errors: GraphQLError[] = [ - { - message: 'An error occurred', - locations: undefined, - path: undefined, - nodes: undefined, - source: undefined, - positions: undefined, - originalError: undefined, - extensions: undefined, - name: 'foo', - }, + const errors: IHttpFetchError[] = [ + new HttpFetchError('There was an error fetching your data.', 'error', {} as any), ]; const component = mountWithIntl( diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx index 80afc2894ea44..ae6a1b892bc99 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx @@ -10,12 +10,13 @@ import { EmptyStateError } from './empty_state_error'; import { EmptyStateLoading } from './empty_state_loading'; import { DataMissing } from './data_missing'; import { StatesIndexStatus } from '../../../../common/runtime_types'; +import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http'; interface EmptyStateProps { children: JSX.Element[] | JSX.Element; statesIndexStatus: StatesIndexStatus | null; loading: boolean; - errors?: Error[]; + errors?: IHttpFetchError[]; } export const EmptyStateComponent = ({ diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx index c8e2bece1cb7f..1135b969018a1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx @@ -7,9 +7,10 @@ import { EuiEmptyPrompt, EuiPanel, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; +import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http'; interface EmptyStateErrorProps { - errors: Error[]; + errors: IHttpFetchError[]; } export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 5f1d790430bdd..2b8bc0bb06ddf 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -80,25 +80,42 @@ exports[`MonitorList component renders a no items message when no data is provid size="m" /> - - + + + + + + + +
@@ -110,6 +127,10 @@ exports[`MonitorList component renders the monitor list 1`] = ` padding-left: 17px; } +.c3 { + padding-top: 12px; +} + .c2 { white-space: nowrap; overflow: hidden; @@ -570,41 +591,81 @@ exports[`MonitorList component renders the monitor list 1`] = ` class="euiSpacer euiSpacer--m" />
- + class="euiPopover__anchor" + > + +
+
- + class="euiFlexItem euiFlexItem--flexGrowZero" + > + +
+
+ +
+ @@ -752,25 +813,42 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` size="m" /> - - + + + + + + + +
diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap index 29ab7a8455fe6..db5bfa72deb36 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap @@ -80,25 +80,42 @@ exports[`MonitorListPagination component renders a no items message when no data size="m" /> - - + + + + + + + +
@@ -247,25 +264,42 @@ exports[`MonitorListPagination component renders the monitor list 1`] = ` size="m" /> - - + + + + + + + + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx index 5cdd1772a7f24..d2030155d0092 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx @@ -87,6 +87,8 @@ describe('MonitorList component', () => { data={{ monitorStates: result }} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); @@ -101,6 +103,8 @@ describe('MonitorList component', () => { data={{}} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); @@ -114,6 +118,8 @@ describe('MonitorList component', () => { data={{ monitorStates: result }} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx new file mode 100644 index 0000000000000..0642712d951fe --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.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 { MonitorListPageSizeSelectComponent } from '../monitor_list_page_size_select'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('MonitorListPageSizeSelect', () => { + it('updates the state when selection changes', () => { + const setSize = jest.fn(); + const setUrlParams = jest.fn(); + const wrapper = mountWithIntl( + + ); + wrapper + .find('[data-test-subj="xpack.uptime.monitorList.pageSizeSelect.popoverOpen"]') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem25"]') + .first() + .simulate('click'); + expect(setSize).toHaveBeenCalledTimes(1); + expect(setSize.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 25, + ], + ] + `); + expect(setUrlParams).toHaveBeenCalledTimes(1); + expect(setUrlParams.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "pagination": undefined, + }, + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx index 1aef9281a3066..b08b8b3fabc3e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx @@ -99,6 +99,8 @@ describe('MonitorListPagination component', () => { dangerColor="danger" data={{ monitorStates: result }} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" hasActiveFilters={false} /> @@ -114,6 +116,8 @@ describe('MonitorListPagination component', () => { data={{}} loading={false} successColor="primary" + pageSize={25} + setPageSize={jest.fn()} hasActiveFilters={false} /> ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx index 58250222e1330..a9fb1ce2f4be1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx @@ -33,6 +33,7 @@ import { MonitorPageLink } from './monitor_page_link'; import { OverviewPageLink } from './overview_page_link'; import * as labels from './translations'; import { MonitorListDrawer } from '../../connected'; +import { MonitorListPageSizeSelect } from './monitor_list_page_size_select'; interface MonitorListQueryResult { monitorStates?: MonitorSummaryResult; @@ -43,6 +44,8 @@ interface MonitorListProps { hasActiveFilters: boolean; successColor: string; linkParameters?: string; + pageSize: number; + setPageSize: (size: number) => void; } type Props = UptimeGraphQLQueryProps & MonitorListProps; @@ -185,20 +188,27 @@ export const MonitorListComponent = (props: Props) => { columns={columns} /> - + - + - + + + + + + + + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx new file mode 100644 index 0000000000000..abfc1384bb1af --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.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 { EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useUrlParams, UpdateUrlParams } from '../../../hooks'; + +interface PopoverButtonProps { + setIsOpen: (isOpen: boolean) => any; + size: number; +} + +const PopoverButton: React.FC = ({ setIsOpen, size }) => ( + setIsOpen(true)} + > + + +); + +interface ContextItemProps { + 'data-test-subj': string; + key: string; + numRows: number; +} + +const items: ContextItemProps[] = [ + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem10', + key: '10 rows', + numRows: 10, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem25', + key: '25 rows', + numRows: 25, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem50', + key: '50 rows', + numRows: 50, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem100', + key: '100 rows', + numRows: 100, + }, +]; + +const LOCAL_STORAGE_KEY = 'xpack.uptime.monitorList.pageSize'; + +interface MonitorListPageSizeSelectProps { + size: number; + setSize: (value: number) => void; +} + +/** + * This component wraps the underlying UI functionality to make the component more testable. + * The features leveraged in this function are tested elsewhere, and are not novel to this component. + */ +export const MonitorListPageSizeSelect: React.FC = ({ + size, + setSize, +}) => { + const [, setUrlParams] = useUrlParams(); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, size.toString()); + }, [size]); + + return ( + + ); +}; + +interface ComponentProps extends MonitorListPageSizeSelectProps { + setUrlParams: UpdateUrlParams; +} + +/** + * This function contains the UI functionality for the page select feature. It's agnostic to any + * external services/features, and focuses only on providing the UI and handling user interaction. + */ +export const MonitorListPageSizeSelectComponent: React.FC = ({ + size, + setSize, + setUrlParams, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(value)} size={size} />} + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="upLeft" + > + ( + { + setSize(numRows); + // reset pagination because the page size has changed + setUrlParams({ pagination: undefined }); + setIsOpen(false); + }} + > + + + ))} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx index 9d8f28cdb34c3..f79da4c98dfed 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx @@ -7,8 +7,13 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; import { useUrlParams } from '../../../hooks'; +const OverviewPageLinkButtonIcon = styled(EuiButtonIcon)` + padding-top: 12px; +`; + interface OverviewPageLinkProps { dataTestSubj: string; direction: string; @@ -38,8 +43,8 @@ export const OverviewPageLink: FunctionComponent = ({ }); return ( - { updateUrlParams({ pagination }); }} diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts index 5252d90215e95..7b9b2d07f2a76 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts @@ -64,3 +64,10 @@ export const UP = i18n.translate('xpack.uptime.monitorList.statusColumn.upLabel' export const DOWN = i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', { defaultMessage: 'Down', }); + +export const RESPONSE_ANOMALY_SCORE = i18n.translate( + 'xpack.uptime.monitorList.anomalyColumn.label', + { + defaultMessage: 'Response Anomaly Score', + } +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_name.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/location_name.tsx similarity index 100% rename from x-pack/legacy/plugins/uptime/public/components/functional/location_name.tsx rename to x-pack/legacy/plugins/uptime/public/components/functional/ping_list/location_name.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx index e8825dacc0078..d245bc1456e6a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx @@ -29,7 +29,7 @@ import { Ping, PingResults } from '../../../../common/graphql/types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; import { pingsQuery } from '../../../queries'; -import { LocationName } from './../location_name'; +import { LocationName } from './location_name'; import { Pagination } from './../monitor_list'; import { PingListExpandedRowComponent } from './expanded_row'; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap new file mode 100644 index 0000000000000..24ef7eda0d129 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ML Confirm Job Delete shallow renders without errors 1`] = ` + + +

+ +

+

+ +

+
+
+`; + +exports[`ML Confirm Job Delete shallow renders without errors while loading 1`] = ` + + +

+ + ) +

+ +
+
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap new file mode 100644 index 0000000000000..2457488c4facc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShowLicenseInfo renders without errors 1`] = ` +Array [ +
+
+ +
+

+ In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license. +

+ + + + Start free 14-day trial + + + +
+
, +
, +] +`; + +exports[`ShowLicenseInfo shallow renders without errors 1`] = ` + + +

+ In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license. +

+ + Start free 14-day trial + +
+ +
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap new file mode 100644 index 0000000000000..354521e7c55b9 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ML Flyout component renders without errors 1`] = ` + + + +

+ Enable anomaly detection +

+
+ +
+ + + +

+ Here you can create a machine learning job to calculate anomaly scores on + response durations for Uptime Monitor. Once enabled, the monitor duration chart on the details page + will show the expected bounds and annotate the graph with anomalies. You can also potentially + identify periods of increased latency across geographical regions. +

+

+ + Machine Learning jobs management page + , + } + } + /> +

+

+ + Note: It might take a few minutes for the job to begin calculating results. + +

+
+ +
+ + + + + Create new job + + + + +
+`; + +exports[`ML Flyout component shows license info if no ml available 1`] = ` +
+
+
+
+